diff --git a/SUBSONIC.md b/SUBSONIC.md index a4bcefb6..e56de8ee 100644 --- a/SUBSONIC.md +++ b/SUBSONIC.md @@ -1,16 +1,21 @@ # Subsonic API -The API version implemented is 1.16.0 and has been tested on _Android_ using _Subsonic Player_, _Ultrasonic_, _Symfonium_, and _DSub_. +The API version implemented is 1.16.0 and has been tested on _Android_ using _DSub_, _Subsonic Player_, _Symfonium_, _Tempo_ and _Ultrasonic_. Folder navigation commands are supported. However, since _LMS_ does not store information for each folder, it is not possible to star/unstar folders considered as artists. Given the API limitations of folder navigation commands, it is recommended to place all tracks of an album in the same folder and not to mix multiple albums in the same folder. The Subsonic API is enabled by default. -__Note__: since _LMS_ may store hashed and salted passwords or may forward authentication requests to external services, it cannot handle the __token authentication__ method. You may need to check your client to make sure to use the __password__ authentication method. Since logins/passwords are passed in plain text through URLs, it is highly recommended to use a unique password when using the Subsonic API. Note that this may affect the use of authentication via PAM. In any case, ensure the web server logs (and proxy logs, if applicable) are properly secured. - # OpenSubsonic API OpenSubsonic is an initiative to patch and extend the legacy Subsonic API. You'll find more details in the [official documentation](https://opensubsonic.netlify.app/) +## Authentication +_LMS_ supports the newer [API Key Authentication](https://opensubsonic.netlify.app/docs/extensions/apikeyauth/). Each user can generate, regenerate, or delete their API key in the settings page. + +By default, API keys can also be used as passwords, and the `user` parameter must match the owner of the API key. This fallback authentication method can be disabled by setting `api-subsonic-support-user-password-auth = false;` in `lms.conf`. + +__Note__: the token+salt authentication method is not supported. + ## Extra fields The following extra fields are implemented: * `Album` response: @@ -50,5 +55,7 @@ The following extra fields are implemented: * `roles` ## Supported extensions +* [API Key Authentication](https://opensubsonic.netlify.app/docs/extensions/apikeyauth/) +* [HTTP form POST](https://opensubsonic.netlify.app/docs/extensions/formpost/) * [Transcode offset](https://opensubsonic.netlify.app/docs/extensions/transcodeoffset/) * [Song Lyrics](https://opensubsonic.netlify.app/docs/extensions/songlyrics/) diff --git a/approot/messages.xml b/approot/messages.xml index e34e36cf..ffee2275 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -289,7 +289,7 @@ Method to sort artist's albums Audio -These audio settings are local to your browser! +These audio settings are local to your browser Internal ListenBrainz ListenBrainz API token @@ -307,6 +307,7 @@ Original Release Date (Descending) Bad password Old password must be filled in +Generate ReplayGain mode No ReplayGain Auto @@ -317,13 +318,19 @@ Scrobbling Services Settings +New settings saved! Artist list mode All artists Album artists Track artists Subsonic API -User Interface +OpenSubsonic API key +Use this API key as the password for clients that do not support the 'API Key Authentication' extension Transcoding +Enable transcoding +Always +Only when format is not supported by the browser +Never Transcoding output bitrate Transcoding output format Matroska/Opus @@ -331,10 +338,6 @@ Ogg/Opus Ogg/Vorbis WebM/Vorbis -Enable transcoding -Always -Only when format is not supported by the browser -Never -New settings saved! +User Interface diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index 99291866..4a7e576c 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -289,7 +289,7 @@ Méthode pour trier les albums de l'artiste Audio -Ces paramètres audio sont locaux à votre navigateur ! +Ces paramètres audio sont locaux à votre navigateur Interne ListenBrainz Jeton d'API ListenBrainz @@ -307,6 +307,7 @@ Date de sortie originale (Décroissant) Mauvais mot de passe L'ancien mot de passe doit être renseigné +Regénérer Mode ReplayGain Pas de ReplayGain Automatique @@ -317,13 +318,19 @@ Scrobbling Services Paramètres +Paramètres sauvegardés ! Mode de listage des artistes Tous les artistes Tous les artistes d'album Tous les artistes de piste API Subsonic -Interface utilisateur +Clef pour l'API OpenSubsonic +Utilisez cette clé d'API comme mot de passe pour les clients qui ne prennent pas en charge l'extension 'API Key Authentication' Transcodage +Appliquer le transcodage +Toujours +Seulement lorsque le format n'est pas pris en charge par votre navigateur +Jamais Bitrate du transcodage Format du transcodage Matroska/Opus @@ -331,11 +338,7 @@ Ogg/Opus Ogg/Vorbis WebM/Vorbis -Appliquer le transcodage -Toujours -Seulement lorsque le format n'est pas pris en charge par votre navigateur -Jamais -Paramètres sauvegardés ! +Interface utilisateur moins d'une seconde diff --git a/approot/messages_it.xml b/approot/messages_it.xml index 749ca015..478c0dc4 100644 --- a/approot/messages_it.xml +++ b/approot/messages_it.xml @@ -46,7 +46,7 @@ C'è stato un errore! Ritorna alla home Album non trovato - +Playlist non trovata Non sei autorizzato ad eseguire questa operazione Utente non trovato @@ -289,7 +289,7 @@ Metodo per ordinare gli album dell'artista Audio -Queste impostazioni sono salvate localmente su questo dispositivo! +Queste impostazioni sono salvate localmente su questo dispositivo Interno ListenBrainz Token API ListenBrainz @@ -307,6 +307,7 @@ Data di uscita originale (Decrescente) Password errata Devi inserire la password attuale +Genera Modalità ReplayGain No ReplayGain Automatica @@ -317,13 +318,19 @@ Scrobbling Servizi Impostazioni +Nuove impostazioni salvate! Modalità di elencazione artisti Tutti gli artisti Artisti album Artisti tracce -Subsonic API -Interfaccia utente +API Subsonic +Chiave API OpenSubsonic +Utilizza questa chiave API come password per i client che non supportano l'estensione 'Autenticazione con chiave API' Transcodifica +Abilita transcodifica +Sempre +Quando il formato non è supportato +Mai Bitrate transcodifica Formato di transcodifica Matroska/Opus @@ -331,11 +338,7 @@ Ogg/Opus Ogg/Vorbis WebM/Vorbis -Abilita transcodifica -Sempre -Quando il formato non è supportato -Mai -Nuove impostazioni salvate! +Interfaccia utente meno di un secondo diff --git a/approot/messages_pl.xml b/approot/messages_pl.xml index ae9bf9c4..b0945b64 100644 --- a/approot/messages_pl.xml +++ b/approot/messages_pl.xml @@ -315,7 +315,7 @@ Metoda sortowania albumów artysty Dźwięk -Te ustawienia dotyczą wyłącznie tej przeglądarki! +Te ustawienia dotyczą wyłącznie tej przeglądarki Wewnętrzny ListenBrainz ListenBrainz API token @@ -333,6 +333,7 @@ Oryginalna data wydania (Malejąco) Błędne hasło Należy podać stare hasło +Generuj Tryb ReplayGain Bez ReplayGain Auto @@ -343,13 +344,19 @@ Scrobbling Usługi Ustawienia +Ustawienia zapisane! Tryb listy artystów Wszyscy artyści Artyści z albumów Artyści ze ścieżek -Subsonic API -Interfejs użytkownika +API Subsonic +Klucz API OpenSubsonic +Użyj tego klucza API jako hasła dla klientów, którzy nie obsługują rozszerzenia 'Uwierzytelnianie za pomocą klucza API' Transkodowanie +Aktywuj transkodowanie +Zawsze +Tylko gdy przeglądarka nie wspiera formatu +Nigdy Przepływność transkodowania Format wyjściowy transkodowania Matroska/Opus @@ -357,11 +364,7 @@ Ogg/Opus Ogg/Vorbis WebM/Vorbis -Aktywuj transkodowanie -Zawsze -Tylko gdy przeglądarka nie wspiera formatu -Nigdy -Ustawienia zapisane! +Interfejs użytkownika mniej niż sekunda diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml index 7b44aa1e..1a127517 100644 --- a/approot/messages_zh.xml +++ b/approot/messages_zh.xml @@ -289,7 +289,7 @@ 音频 -这些音频设置仅保存在您的本地浏览器! +这些音频设置仅保存在您的本地浏览器 @@ -307,6 +307,7 @@ 无效密码 必须填写旧密码 + ReplayGain 模式 没有 ReplayGain 自动模式 @@ -317,13 +318,19 @@ +新设置已保存! 歌手列表模式 所有歌手 专辑歌手 音轨歌手 Subsonic 应用程序接口 + 转码 +启动转码 +总是 +仅当格式不支持时使用 +从不 转码比特率 转码格式 Matroska/Opus @@ -331,11 +338,6 @@ Ogg/Opus Ogg/Vorbis WebM/Vorbis -启动转码 -总是 -仅当格式不支持时使用 -从不 -新设置已保存! diff --git a/approot/misc.xml b/approot/misc.xml index 528c6685..78ebf5b2 100644 --- a/approot/misc.xml +++ b/approot/misc.xml @@ -19,5 +19,6 @@ + diff --git a/approot/settings.xml b/approot/settings.xml index 1ee166e7..e839a8e1 100644 --- a/approot/settings.xml +++ b/approot/settings.xml @@ -85,6 +85,25 @@ ${} ${tr:Lms.Settings.subsonic-api} +
+ +
+ ${subsonic-token-regen-btn class="btn btn-outline-secondary"} + ${subsonic-token-del-btn class="btn btn-outline-warning"} + ${subsonic-token class="form-control"} + ${subsonic-token-visibility-btn class="btn btn-outline-secondary"} +
+ ${subsonic-token-info class="help-block"} +
+
+
${subsonic-enable-transcoding-by-default class="form-check-input"} @@ -150,9 +169,12 @@ - ${listenbrainz-token class="form-control"} -
- ${listenbrainz-token-info class="help-block"} +
+ ${listenbrainz-token class="form-control"} + ${listenbrainz-token-visibility-btn class="btn btn-outline-secondary"} +
+ ${listenbrainz-token-info class="help-block"} +
${} diff --git a/conf/lms.conf b/conf/lms.conf index ea9744dc..56822c3f 100644 --- a/conf/lms.conf +++ b/conf/lms.conf @@ -60,6 +60,9 @@ acousticbrainz-api-base-url = "https://acousticbrainz.org"; # Authentication # Available backends: "internal", "PAM", "http-headers" authentication-backend = "internal"; +# The number of bcrypt rounds to be used when backend is set to "internal". The higher the more secure +internal-password-bcrypt-round = 12; +# The header to be used to read the authentication user when backend is set to "http-headers" http-headers-login-field = "X-Forwarded-User"; # Max entries in the login throttler (1 entry per IP address. For IPv6, the whole /64 block is used) @@ -68,6 +71,10 @@ 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; + # 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 api-subsonic-old-server-protocol-clients = ("DSub"); diff --git a/src/libs/core/include/core/Service.hpp b/src/libs/core/include/core/Service.hpp index b6603fc2..517665fb 100644 --- a/src/libs/core/include/core/Service.hpp +++ b/src/libs/core/include/core/Service.hpp @@ -24,7 +24,8 @@ namespace lms::core { - template + // Tag can be used if you have multiple services sharing the same interface + template class Service { public: @@ -46,12 +47,12 @@ namespace lms::core Class* operator->() const { - return Service::get(); + return Service::get(); } Class& operator*() const { - return *Service::get(); + return *Service::get(); } static Class* get() { return _service.get(); } diff --git a/src/libs/core/test/CMakeLists.txt b/src/libs/core/test/CMakeLists.txt index a063da46..7b6aced5 100644 --- a/src/libs/core/test/CMakeLists.txt +++ b/src/libs/core/test/CMakeLists.txt @@ -5,6 +5,7 @@ add_executable(test-core LiteralString.cpp Path.cpp RecursiveSharedMutex.cpp + Service.cpp String.cpp TraceLogger.cpp Utils.cpp diff --git a/src/libs/core/test/Service.cpp b/src/libs/core/test/Service.cpp new file mode 100644 index 00000000..0f648f5a --- /dev/null +++ b/src/libs/core/test/Service.cpp @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2019 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 "core/Service.hpp" + +namespace lms::core::tests +{ + class IMyService + { + }; + + class MyService : public IMyService + { + }; + + class MyOtherService : public IMyService + { + }; + + class MyServiceTag + { + }; + class MyOtherServiceTag + { + }; + + TEST(Service, ctr) + { + EXPECT_FALSE(Service().exists()); + EXPECT_EQ(Service().get(), nullptr); + + Service myService{ std::make_unique() }; + + EXPECT_TRUE(Service().exists()); + EXPECT_EQ(Service().get(), myService.get()); + } + + TEST(Service, tags) + { + Service myService{ std::make_unique() }; + Service myOtherService{ std::make_unique() }; + + EXPECT_FALSE(Service().exists()); + EXPECT_EQ(Service().get(), nullptr); + + EXPECT_TRUE((Service().exists())); + EXPECT_TRUE((Service().exists())); + EXPECT_EQ((Service().get()), myService.get()); + EXPECT_EQ((Service().get()), myOtherService.get()); + } +} // namespace lms::core::tests \ No newline at end of file diff --git a/src/libs/database/impl/AuthToken.cpp b/src/libs/database/impl/AuthToken.cpp index 5dea83c0..d963c653 100644 --- a/src/libs/database/impl/AuthToken.cpp +++ b/src/libs/database/impl/AuthToken.cpp @@ -30,29 +30,65 @@ namespace lms::db { - AuthToken::AuthToken(std::string_view value, const Wt::WDateTime& expiry, ObjectPtr user) - : _value{ value } + AuthToken::AuthToken(std::string_view domain, std::string_view value, const Wt::WDateTime& expiry, std::optional maxUseCount, ObjectPtr user) + : _domain{ domain } + , _value{ value } , _expiry{ expiry } + , _maxUseCount{ maxUseCount } , _user{ getDboPtr(user) } { } - AuthToken::pointer AuthToken::create(Session& session, std::string_view value, const Wt::WDateTime& expiry, ObjectPtr user) + AuthToken::pointer AuthToken::create(Session& session, std::string_view domain, std::string_view value, const Wt::WDateTime& expiry, std::optional maxUseCount, ObjectPtr user) { - return session.getDboSession()->add(std::unique_ptr{ new AuthToken{ value, expiry, user } }); + return session.getDboSession()->add(std::unique_ptr{ new AuthToken{ domain, value, expiry, maxUseCount, user } }); } - void AuthToken::removeExpiredTokens(Session& session, const Wt::WDateTime& now) - { - session.checkWriteTransaction(); - - utils::executeCommand(*session.getDboSession(), "DELETE FROM auth_token WHERE expiry < ?", now); - } - - AuthToken::pointer AuthToken::find(Session& session, std::string_view value) + std::size_t AuthToken::getCount(Session& session) { session.checkReadTransaction(); - return utils::fetchQuerySingleResult(session.getDboSession()->find().where("value = ?").bind(value)); + return utils::fetchQuerySingleResult(session.getDboSession()->query("SELECT COUNT(*) FROM auth_token")); + } + + AuthToken::pointer AuthToken::find(Session& session, AuthTokenId id) + { + return utils::fetchQuerySingleResult(session.getDboSession()->query>("SELECT a_t from auth_token a_t").where("a_t.id = ?").bind(id)); + } + + AuthToken::pointer AuthToken::find(Session& session, std::string_view domain, std::string_view value) + { + session.checkReadTransaction(); + + auto query{ session.getDboSession()->find() }; + query.where("domain = ?").bind(domain); + query.where("value = ?").bind(value); + + return utils::fetchQuerySingleResult(query); + } + + void AuthToken::find(Session& session, std::string_view domain, UserId userId, std::function visitor) + { + session.checkReadTransaction(); + + auto query{ session.getDboSession()->find() }; + query.where("domain = ?").bind(domain); + query.where("user_id = ?").bind(userId); + + utils::forEachQueryResult(query, visitor); + } + + void AuthToken::removeExpiredTokens(Session& session, std::string_view domain, const Wt::WDateTime& now) + { + session.checkWriteTransaction(); + + utils::executeCommand(*session.getDboSession(), "DELETE FROM auth_token WHERE expiry < ? AND domain = ?", now, domain); + } + + void AuthToken::clearUserTokens(Session& session, std::string_view domain, UserId user) + { + session.checkWriteTransaction(); + + utils::executeCommand(*session.getDboSession(), "DELETE FROM auth_token WHERE user_id = ? AND domain = ?", user, domain); } } // namespace lms::db diff --git a/src/libs/database/impl/Migration.cpp b/src/libs/database/impl/Migration.cpp index f8c419fd..44378136 100644 --- a/src/libs/database/impl/Migration.cpp +++ b/src/libs/database/impl/Migration.cpp @@ -35,7 +35,7 @@ namespace lms::db { namespace { - static constexpr Version LMS_DATABASE_VERSION{ 74 }; + static constexpr Version LMS_DATABASE_VERSION{ 76 }; } VersionInfo::VersionInfo() @@ -947,6 +947,27 @@ SELECT utils::executeCommand(*session.getDboSession(), "UPDATE media_library SET path = rtrim(path, '/') WHERE path LIKE '%/'"); } + void migrateFromV74(Session& session) + { + // New auth token authentication for Subsonic API + // Previous tokens are not usable any more, no problem since they are just used for the ui's "remember me" feature + utils::executeCommand(*session.getDboSession(), "DELETE FROM auth_token"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE auth_token ADD domain TEXT NOT NULL"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE auth_token ADD use_count INTEGER NOT NULL"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE auth_token ADD last_used TEXT"); + utils::executeCommand(*session.getDboSession(), "ALTER TABLE auth_token ADD max_use_count INTEGER"); + + utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS auth_token_user_idx"); + utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS auth_token_expiry_idx"); + utils::executeCommand(*session.getDboSession(), "DROP INDEX IF EXISTS auth_token_value_idx"); + } + + void migrateFromV75(Session& session) + { + // Added a new option to set the bcrypt count to be use to hash user's passwords + utils::executeCommand(*session.getDboSession(), "ALTER TABLE user ADD bcrypt_round_count INTEGER NOT NULL DEFAULT(7)"); + } + bool doDbMigration(Session& session) { constexpr std::string_view outdatedMsg{ "Outdated database, please rebuild it (delete the .db file and restart)" }; @@ -997,6 +1018,8 @@ SELECT { 71, migrateFromV71 }, { 72, migrateFromV72 }, { 73, migrateFromV73 }, + { 74, migrateFromV74 }, + { 75, migrateFromV75 }, }; bool migrationPerformed{}; @@ -1028,7 +1051,9 @@ SELECT LMS_LOG(DB, INFO, "Migrating database from version " << version << " to " << version + 1 << "..."); auto itMigrationFunc{ migrationFunctions.find(version) }; - assert(itMigrationFunc != std::cend(migrationFunctions)); + if (itMigrationFunc == std::cend(migrationFunctions)) + throw core::LmsException{ "No code found to upgrade database!" }; + itMigrationFunc->second(session); VersionInfo::get(session).modify()->setVersion(++version); diff --git a/src/libs/database/impl/Session.cpp b/src/libs/database/impl/Session.cpp index afd1b89a..d03389e2 100644 --- a/src/libs/database/impl/Session.cpp +++ b/src/libs/database/impl/Session.cpp @@ -188,9 +188,9 @@ namespace lms::db utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_sort_name_nocase_idx ON artist(sort_name COLLATE NOCASE)"); utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS artist_mbid_idx ON artist(mbid)"); - utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_user_idx ON auth_token(user_id)"); - utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_expiry_idx ON auth_token(expiry)"); - utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_value_idx ON auth_token(value)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_user_domain_idx ON auth_token(user_id, domain)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_domain_expiry_idx ON auth_token(domain, expiry)"); + utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS auth_token_domain_value_idx ON auth_token(domain, value)"); utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS cluster_cluster_type_idx ON cluster(cluster_type_id)"); utils::executeCommand(_session, "CREATE INDEX IF NOT EXISTS cluster_type_name_idx ON cluster_type(name)"); diff --git a/src/libs/database/impl/User.cpp b/src/libs/database/impl/User.cpp index ecb11727..ef852783 100644 --- a/src/libs/database/impl/User.cpp +++ b/src/libs/database/impl/User.cpp @@ -97,10 +97,4 @@ namespace lms::db assert(isAudioBitrateAllowed(bitrate)); _subsonicDefaultTranscodingOutputBitrate = bitrate; } - - void User::clearAuthTokens() - { - _authTokens.clear(); - } - } // namespace lms::db diff --git a/src/libs/database/include/database/AuthToken.hpp b/src/libs/database/include/database/AuthToken.hpp index 474f0386..720716aa 100644 --- a/src/libs/database/include/database/AuthToken.hpp +++ b/src/libs/database/include/database/AuthToken.hpp @@ -19,6 +19,7 @@ #pragma once +#include #include #include @@ -26,41 +27,62 @@ #include "database/AuthTokenId.hpp" #include "database/Object.hpp" +#include "database/Types.hpp" +#include "database/UserId.hpp" namespace lms::db { class Session; - class User; + class AuthToken final : public Object { public: AuthToken() = default; // Utility - static void removeExpiredTokens(Session& session, const Wt::WDateTime& now); - static pointer find(Session& session, std::string_view value); + static std::size_t getCount(Session& session); + static pointer find(Session& session, AuthTokenId tokenId); + static pointer find(Session& session, std::string_view domain, std::string_view value); + static void find(Session& session, std::string_view domain, UserId userId, std::function visitor); + static void removeExpiredTokens(Session& session, std::string_view domain, const Wt::WDateTime& now); + static void clearUserTokens(Session& session, std::string_view domain, UserId user); // Accessors const Wt::WDateTime& getExpiry() const { return _expiry; } ObjectPtr getUser() const { return _user; } const std::string& getValue() const { return _value; } + std::size_t getUseCount() const { return _useCount; } + Wt::WDateTime getLastUsed() const { return _lastUsed; } + std::optional getMaxUseCount() const { return _maxUseCount; } + + // Setters + std::size_t incUseCount() { return ++_useCount; } + void setLastUsed(const Wt::WDateTime& lastUsed) { _lastUsed = lastUsed; } template void persist(Action& a) { + Wt::Dbo::field(a, _domain, "domain"); Wt::Dbo::field(a, _value, "value"); Wt::Dbo::field(a, _expiry, "expiry"); + Wt::Dbo::field(a, _useCount, "use_count"); + Wt::Dbo::field(a, _lastUsed, "last_used"); + Wt::Dbo::field(a, _maxUseCount, "max_use_count"); Wt::Dbo::belongsTo(a, _user, "user", Wt::Dbo::OnDeleteCascade); } private: friend class Session; - AuthToken(std::string_view value, const Wt::WDateTime& expiry, ObjectPtr user); - static pointer create(Session& session, std::string_view value, const Wt::WDateTime& expiry, ObjectPtr user); + AuthToken(std::string_view domain, std::string_view value, const Wt::WDateTime& expiry, std::optional maxUseCount, ObjectPtr user); + static pointer create(Session& session, std::string_view domain, std::string_view value, const Wt::WDateTime& expiry, std::optional maxUseCount, ObjectPtr user); + std::string _domain; std::string _value; Wt::WDateTime _expiry; + long _useCount{}; + Wt::WDateTime _lastUsed; + std::optional _maxUseCount; Wt::Dbo::ptr _user; }; -} // namespace lms::db +} // namespace lms::db \ No newline at end of file diff --git a/src/libs/database/include/database/User.hpp b/src/libs/database/include/database/User.hpp index cbf77810..0ec076b2 100644 --- a/src/libs/database/include/database/User.hpp +++ b/src/libs/database/include/database/User.hpp @@ -42,6 +42,7 @@ namespace lms::db public: struct PasswordHash { + std::size_t bcryptRoundCount; std::string salt; std::string hash; }; @@ -91,7 +92,7 @@ namespace lms::db // accessors const std::string& getLoginName() const { return _loginName; } - PasswordHash getPasswordHash() const { return PasswordHash{ _passwordSalt, _passwordHash }; } + PasswordHash getPasswordHash() const { return PasswordHash{ .bcryptRoundCount = static_cast(_bcryptRoundCount), .salt = _passwordSalt, .hash = _passwordHash }; } const Wt::WDateTime& getLastLogin() const { return _lastLogin; } std::size_t getAuthTokensCount() const { return _authTokens.size(); } @@ -99,6 +100,7 @@ namespace lms::db void setLastLogin(const Wt::WDateTime& dateTime) { _lastLogin = dateTime; } void setPasswordHash(const PasswordHash& passwordHash) { + _bcryptRoundCount = passwordHash.bcryptRoundCount; _passwordSalt = passwordHash.salt; _passwordHash = passwordHash.hash; } @@ -108,7 +110,6 @@ namespace lms::db void setSubsonicDefaultTranscodingOutputBitrate(Bitrate bitrate); void setUITheme(UITheme uiTheme) { _uiTheme = uiTheme; } void setUIArtistReleaseSortMethod(ReleaseSortMethod method) { _uiArtistReleaseSortMethod = method; } - void clearAuthTokens(); void setSubsonicArtistListMode(SubsonicArtistListMode mode) { _subsonicArtistListMode = mode; } void setFeedbackBackend(FeedbackBackend feedbackBackend) { _feedbackBackend = feedbackBackend; } void setScrobblingBackend(ScrobblingBackend scrobblingBackend) { _scrobblingBackend = scrobblingBackend; } @@ -133,6 +134,7 @@ namespace lms::db { Wt::Dbo::field(a, _type, "type"); Wt::Dbo::field(a, _loginName, "login_name"); + Wt::Dbo::field(a, _bcryptRoundCount, "bcrypt_round_count"); Wt::Dbo::field(a, _passwordSalt, "password_salt"); Wt::Dbo::field(a, _passwordHash, "password_hash"); Wt::Dbo::field(a, _lastLogin, "last_login"); @@ -156,6 +158,7 @@ namespace lms::db static pointer create(Session& session, std::string_view loginName); std::string _loginName; + int _bcryptRoundCount{}; std::string _passwordSalt; std::string _passwordHash; Wt::WDateTime _lastLogin; diff --git a/src/libs/database/test/AuthToken.cpp b/src/libs/database/test/AuthToken.cpp new file mode 100644 index 00000000..3cf6fc91 --- /dev/null +++ b/src/libs/database/test/AuthToken.cpp @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2024 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 "Common.hpp" + +#include "database/AuthToken.hpp" + +namespace lms::db::tests +{ + using ScopedAuthToken = ScopedEntity; + + TEST_F(DatabaseFixture, AuthTokens) + { + ScopedUser user{ session, "MyUser" }; + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(AuthToken::getCount(session), 0); + } + + ScopedAuthToken token{ session, "myDomain", "foo", Wt::WDateTime{}, std::nullopt, user.lockAndGet() }; + + { + auto transaction{ session.createReadTransaction() }; + EXPECT_EQ(AuthToken::getCount(session), 1); + } + + { + auto transaction{ session.createWriteTransaction() }; + AuthToken::clearUserTokens(session, "nonExistingDomain", user.getId()); + } + } +} // namespace lms::db::tests \ No newline at end of file diff --git a/src/libs/database/test/CMakeLists.txt b/src/libs/database/test/CMakeLists.txt index 25d651ff..60ea71d4 100644 --- a/src/libs/database/test/CMakeLists.txt +++ b/src/libs/database/test/CMakeLists.txt @@ -1,5 +1,6 @@ add_executable(test-database + AuthToken.cpp Artist.cpp Cluster.cpp Common.cpp diff --git a/src/libs/database/test/Migration.cpp b/src/libs/database/test/Migration.cpp index 9aecf7aa..8a00ec61 100644 --- a/src/libs/database/test/Migration.cpp +++ b/src/libs/database/test/Migration.cpp @@ -20,6 +20,7 @@ #include "Common.hpp" #include "core/String.hpp" +#include "database/AuthToken.hpp" #include "database/Db.hpp" #include "database/Directory.hpp" #include "database/Image.hpp" @@ -337,6 +338,7 @@ VALUES auto transaction{ session.createReadTransaction() }; EXPECT_FALSE(Artist::find(session, ArtistId{})); + EXPECT_FALSE(AuthToken::find(session, AuthTokenId{})); EXPECT_FALSE(Cluster::find(session, ClusterId{})); EXPECT_FALSE(ClusterType::find(session, ClusterTypeId{})); EXPECT_FALSE(Directory::find(session, DirectoryId{})); diff --git a/src/libs/services/auth/impl/AuthTokenService.cpp b/src/libs/services/auth/impl/AuthTokenService.cpp index 85196f72..34055746 100644 --- a/src/libs/services/auth/impl/AuthTokenService.cpp +++ b/src/libs/services/auth/impl/AuthTokenService.cpp @@ -20,7 +20,6 @@ #include "AuthTokenService.hpp" #include -#include #include #include "core/Exception.hpp" @@ -32,72 +31,95 @@ namespace lms::auth { - - std::unique_ptr createAuthTokenService(db::Db& db, std::size_t maxThrottlerEntries) + namespace { - return std::make_unique(db, maxThrottlerEntries); + AuthTokenService::AuthTokenInfo createAuthTokenInfo(const db::AuthToken::pointer& authToken) + { + return AuthTokenService::AuthTokenInfo{ + .userId = authToken->getUser()->getId(), + .expiry = authToken->getExpiry(), + .lastUsed = authToken->getLastUsed(), + .useCount = authToken->getUseCount(), + .maxUseCount = authToken->getMaxUseCount(), + }; + } + } // namespace + + std::unique_ptr createAuthTokenService(db::Db& db, std::size_t maxThrottlerEntryCount) + { + return std::make_unique(db, maxThrottlerEntryCount); } - static const Wt::Auth::SHA1HashFunction sha1Function; - - AuthTokenService::AuthTokenService(db::Db& db, std::size_t maxThrottlerEntries) + AuthTokenService::AuthTokenService(db::Db& db, std::size_t maxThrottlerEntryCount) : AuthServiceBase{ db } - , _loginThrottler{ maxThrottlerEntries } + , _loginThrottler{ maxThrottlerEntryCount } { } - std::string - AuthTokenService::createAuthToken(db::UserId userId, const Wt::WDateTime& expiry) + void AuthTokenService::registerDomain(core::LiteralString domain, const DomainParameters& params) { - const std::string secret{ Wt::WRandom::generateId(32) }; - const std::string secretHash{ sha1Function.compute(secret, {}) }; - - db::Session& session{ getDbSession() }; - - auto transaction{ session.createWriteTransaction() }; - - db::User::pointer user{ db::User::find(session, userId) }; - if (!user) - throw Exception{ "User deleted" }; - - db::AuthToken::pointer authToken{ session.create(secretHash, expiry, user) }; - - LMS_LOG(UI, DEBUG, "Created auth token for user '" << user->getLoginName() << "', expiry = " << expiry.toString()); - - if (user->getAuthTokensCount() >= 50) - db::AuthToken::removeExpiredTokens(session, Wt::WDateTime::currentDateTime()); - - return secret; + auto [it, inserted]{ _domainParameters.emplace(domain, params) }; + if (!inserted) + throw Exception{ "Auth token domain already registered!" }; } - std::optional - AuthTokenService::processAuthToken(std::string_view secret) + void AuthTokenService::createAuthToken(core::LiteralString domain, db::UserId userId, std::string_view token) { - const std::string secretHash{ sha1Function.compute(std::string{ secret }, {}) }; + const DomainParameters& params{ getDomainParameters(domain) }; + db::Session& session{ getDbSession() }; + const auto now{ Wt::WDateTime::currentDateTime() }; + const auto expiry{ params.tokenDuration ? now.addSecs(std::chrono::duration_cast(params.tokenDuration.value()).count()) : Wt::WDateTime{} }; + + { + auto transaction{ session.createWriteTransaction() }; + + const db::User::pointer user{ db::User::find(session, userId) }; + if (!user) + throw Exception{ "User deleted" }; + + const db::AuthToken::pointer authToken{ session.create(domain.str(), token, expiry, params.tokenMaxUseCount, user) }; + + LMS_LOG(UI, DEBUG, "Created auth token for user '" << user->getLoginName() << "', expiry = " << authToken->getExpiry().toString() << ", maxUseCount = " << (authToken->getMaxUseCount() ? std::to_string(*authToken->getMaxUseCount()) : "")); + + // TODO per domain + if (user->getAuthTokensCount() >= 50) + db::AuthToken::removeExpiredTokens(session, domain.str(), Wt::WDateTime::currentDateTime()); + } + } + + std::optional AuthTokenService::processAuthToken(core::LiteralString domain, std::string_view token) + { db::Session& session{ getDbSession() }; auto transaction{ session.createWriteTransaction() }; - db::AuthToken::pointer authToken{ db::AuthToken::find(session, secretHash) }; + db::AuthToken::pointer authToken{ db::AuthToken::find(session, domain.str(), token) }; if (!authToken) return std::nullopt; - if (authToken->getExpiry() < Wt::WDateTime::currentDateTime()) + if (authToken->getExpiry().isValid() && authToken->getExpiry() < Wt::WDateTime::currentDateTime()) { authToken.remove(); return std::nullopt; } - LMS_LOG(UI, DEBUG, "Found auth token for user '" << authToken->getUser()->getLoginName() << "'!"); + LMS_LOG(UI, DEBUG, "Found auth token for user '" << authToken->getUser()->getLoginName() << "' on domain '" << domain.str() << "'"); - AuthTokenService::AuthTokenProcessResult::AuthTokenInfo res{ authToken->getUser()->getId(), authToken->getExpiry() }; - authToken.remove(); + AuthTokenInfo res{ createAuthTokenInfo(authToken) }; + + const std::size_t tokenUseCount{ authToken.modify()->incUseCount() }; + authToken.modify()->setLastUsed(Wt::WDateTime::currentDateTime()); + + if (auto maxUseCount{ authToken->getMaxUseCount() }) + { + if (*maxUseCount >= tokenUseCount) + authToken.remove(); + } return res; } - AuthTokenService::AuthTokenProcessResult - AuthTokenService::processAuthToken(const boost::asio::ip::address& clientAddress, std::string_view tokenValue) + AuthTokenService::AuthTokenProcessResult AuthTokenService::processAuthToken(core::LiteralString domain, const boost::asio::ip::address& clientAddress, std::string_view tokenValue) { // Do not waste too much resource on brute force attacks (optim) { @@ -107,7 +129,7 @@ namespace lms::auth return AuthTokenProcessResult{ AuthTokenProcessResult::State::Throttled }; } - auto res{ processAuthToken(tokenValue) }; + auto res{ processAuthToken(domain, tokenValue) }; { std::unique_lock lock{ _mutex }; @@ -122,22 +144,40 @@ namespace lms::auth _loginThrottler.onGoodClientAttempt(clientAddress); onUserAuthenticated(res->userId); - return AuthTokenProcessResult{ AuthTokenProcessResult::State::Granted, std::move(*res) }; + return AuthTokenProcessResult{ AuthTokenProcessResult::State::Granted, res }; } } - void - AuthTokenService::clearAuthTokens(db::UserId userId) + void AuthTokenService::visitAuthTokens(core::LiteralString domain, db::UserId userId, std::function visitor) { db::Session& session{ getDbSession() }; - auto transaction{ session.createWriteTransaction() }; + { + auto transaction{ session.createReadTransaction() }; - db::User::pointer user{ db::User::find(session, userId) }; - if (!user) - throw Exception{ "User deleted" }; - - user.modify()->clearAuthTokens(); + db::AuthToken::find(session, domain.str(), userId, [&](const db::AuthToken::pointer& authToken) { + const AuthTokenInfo info{ createAuthTokenInfo(authToken) }; + visitor(info, authToken->getValue()); + }); + } } + void AuthTokenService::clearAuthTokens(core::LiteralString domain, db::UserId userId) + { + db::Session& session{ getDbSession() }; + + { + auto transaction{ session.createWriteTransaction() }; + db::AuthToken::clearUserTokens(session, domain.str(), userId); + } + } + + const AuthTokenService::DomainParameters& AuthTokenService::getDomainParameters(core::LiteralString domain) const + { + auto it{ _domainParameters.find(domain) }; + if (it == std::cend(_domainParameters)) + throw Exception{ "Invalid auth token domain" }; + + return it->second; + } } // namespace lms::auth diff --git a/src/libs/services/auth/impl/AuthTokenService.hpp b/src/libs/services/auth/impl/AuthTokenService.hpp index f3160aba..0f4e8e04 100644 --- a/src/libs/services/auth/impl/AuthTokenService.hpp +++ b/src/libs/services/auth/impl/AuthTokenService.hpp @@ -19,6 +19,7 @@ #pragma once +#include #include #include "services/auth/IAuthTokenService.hpp" @@ -36,7 +37,7 @@ namespace lms::auth class AuthTokenService : public IAuthTokenService, public AuthServiceBase { public: - AuthTokenService(db::Db& db, std::size_t maxThrottlerEntries); + AuthTokenService(db::Db& db, std::size_t maxThrottlerEntryCount); AuthTokenService(const AuthTokenService&) = delete; AuthTokenService& operator=(const AuthTokenService&) = delete; @@ -44,13 +45,17 @@ namespace lms::auth AuthTokenService& operator=(AuthTokenService&&) = delete; private: - AuthTokenProcessResult processAuthToken(const boost::asio::ip::address& clientAddress, std::string_view tokenValue) override; - std::string createAuthToken(db::UserId userId, const Wt::WDateTime& expiry) override; - void clearAuthTokens(db::UserId userId) override; + void registerDomain(core::LiteralString domain, const DomainParameters& params) 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; + void clearAuthTokens(core::LiteralString domain, db::UserId userId) override; - std::optional processAuthToken(std::string_view secret); + std::optional processAuthToken(core::LiteralString domain, std::string_view tokenValue); + const DomainParameters& getDomainParameters(core::LiteralString domain) const; std::shared_mutex _mutex; + std::map _domainParameters; LoginThrottler _loginThrottler; }; } // namespace lms::auth diff --git a/src/libs/services/auth/impl/PasswordServiceBase.cpp b/src/libs/services/auth/impl/PasswordServiceBase.cpp index 4628e845..4a7a8b91 100644 --- a/src/libs/services/auth/impl/PasswordServiceBase.cpp +++ b/src/libs/services/auth/impl/PasswordServiceBase.cpp @@ -37,28 +37,24 @@ namespace lms::auth { static const Wt::Auth::SHA1HashFunction sha1Function; - std::unique_ptr - createPasswordService(std::string_view passwordAuthenticationBackend, db::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService) + std::unique_ptr createPasswordService(std::string_view backend, db::Db& db, std::size_t maxThrottlerEntryCount) { - if (passwordAuthenticationBackend == "internal") - return std::make_unique(db, maxThrottlerEntries, authTokenService); + if (backend == "internal") + return std::make_unique(db, maxThrottlerEntryCount); #ifdef LMS_SUPPORT_PAM - else if (passwordAuthenticationBackend == "pam") - return std::make_unique(db, maxThrottlerEntries, authTokenService); + if (backend == "PAM") + return std::make_unique(db, maxThrottlerEntryCount); #endif // LMS_SUPPORT_PAM - - throw Exception{ "Authentication backend '" + std::string{ passwordAuthenticationBackend } + "' is not supported!" }; + throw Exception{ "Authentication backend '" + std::string{ backend } + "' not supported!" }; } - PasswordServiceBase::PasswordServiceBase(db::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService) + PasswordServiceBase::PasswordServiceBase(db::Db& db, std::size_t maxThrottlerEntries) : AuthServiceBase{ db } , _loginThrottler{ maxThrottlerEntries } - , _authTokenService{ authTokenService } { } - PasswordServiceBase::CheckResult - PasswordServiceBase::checkUserPassword(const boost::asio::ip::address& clientAddress, std::string_view loginName, std::string_view password) + PasswordServiceBase::CheckResult PasswordServiceBase::checkUserPassword(const boost::asio::ip::address& clientAddress, std::string_view loginName, std::string_view password) { LMS_LOG(AUTH, DEBUG, "Checking password for user '" << loginName << "'"); diff --git a/src/libs/services/auth/impl/PasswordServiceBase.hpp b/src/libs/services/auth/impl/PasswordServiceBase.hpp index 1c32f5b9..ac75290f 100644 --- a/src/libs/services/auth/impl/PasswordServiceBase.hpp +++ b/src/libs/services/auth/impl/PasswordServiceBase.hpp @@ -36,16 +36,13 @@ namespace lms::auth class PasswordServiceBase : public IPasswordService, public AuthServiceBase { public: - PasswordServiceBase(db::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService); + PasswordServiceBase(db::Db& db, std::size_t maxThrottlerEntries); PasswordServiceBase(const PasswordServiceBase&) = delete; PasswordServiceBase& operator=(const PasswordServiceBase&) = delete; PasswordServiceBase(PasswordServiceBase&&) = delete; PasswordServiceBase& operator=(PasswordServiceBase&&) = delete; - protected: - IAuthTokenService& getAuthTokenService() { return _authTokenService; } - private: virtual bool checkUserPassword(std::string_view loginName, std::string_view password) = 0; @@ -55,6 +52,5 @@ namespace lms::auth std::shared_mutex _mutex; LoginThrottler _loginThrottler; - IAuthTokenService& _authTokenService; }; } // namespace lms::auth diff --git a/src/libs/services/auth/impl/http-headers/HttpHeadersEnvService.cpp b/src/libs/services/auth/impl/http-headers/HttpHeadersEnvService.cpp index ae0c7b85..9c065e7f 100644 --- a/src/libs/services/auth/impl/http-headers/HttpHeadersEnvService.cpp +++ b/src/libs/services/auth/impl/http-headers/HttpHeadersEnvService.cpp @@ -38,25 +38,25 @@ namespace lms::auth { const std::string loginName{ env.headerValue(_fieldName) }; if (loginName.empty()) - return { CheckResult::State::Denied }; + return CheckResult{ .state = CheckResult::State::Denied, .userId = {} }; LMS_LOG(AUTH, DEBUG, "Extracted login name = '" << loginName << "' from HTTP header"); const db::UserId userId{ getOrCreateUser(loginName) }; onUserAuthenticated(userId); - return { CheckResult::State::Granted, userId }; + return CheckResult{ .state = CheckResult::State::Granted, .userId = userId }; } HttpHeadersEnvService::CheckResult HttpHeadersEnvService::processRequest(const Wt::Http::Request& request) { const std::string loginName{ request.headerValue(_fieldName) }; if (loginName.empty()) - return { CheckResult::State::Denied }; + return CheckResult{ .state = CheckResult::State::Denied, .userId = {} }; LMS_LOG(AUTH, DEBUG, "Extracted login name = '" << loginName << "' from HTTP header"); const db::UserId userId{ getOrCreateUser(loginName) }; onUserAuthenticated(userId); - return { CheckResult::State::Granted, userId }; + return { .state = CheckResult::State::Granted, .userId = userId }; } } // namespace lms::auth diff --git a/src/libs/services/auth/impl/internal/InternalPasswordService.cpp b/src/libs/services/auth/impl/internal/InternalPasswordService.cpp index 47b9fefe..013923e0 100644 --- a/src/libs/services/auth/impl/internal/InternalPasswordService.cpp +++ b/src/libs/services/auth/impl/internal/InternalPasswordService.cpp @@ -21,18 +21,21 @@ #include -#include "core/Exception.hpp" +#include "core/IConfig.hpp" #include "core/ILogger.hpp" #include "database/Session.hpp" #include "database/User.hpp" -#include "services/auth/IAuthTokenService.hpp" #include "services/auth/Types.hpp" namespace lms::auth { - InternalPasswordService::InternalPasswordService(db::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService) - : PasswordServiceBase{ db, maxThrottlerEntries, authTokenService } + InternalPasswordService::InternalPasswordService(db::Db& db, std::size_t maxThrottlerEntries) + : PasswordServiceBase{ db, maxThrottlerEntries } + , _bcryptRoundCount{ static_cast(core::Service::get()->getULong("internal-password-bcrypt-round", 12)) } { + if (_bcryptRoundCount < 7 || _bcryptRoundCount > 31) + throw Exception{ "\"internal-password-bcrypt-round\" must be in range 7-31" }; + _validator.setMinimumLength(Wt::Auth::PasswordStrengthType::OneCharClass, 4); _validator.setMinimumLength(Wt::Auth::PasswordStrengthType::TwoCharClass, 4); _validator.setMinimumLength(Wt::Auth::PasswordStrengthType::PassPhrase, 4); @@ -55,7 +58,7 @@ namespace lms::auth if (!user) { LMS_LOG(AUTH, DEBUG, "hashing random stuff"); - // hash random stuff here to waste some time + // hash random stuff here to waste some time, don't give clue the user does not exist hashRandomPassword(); return false; } @@ -64,13 +67,29 @@ namespace lms::auth passwordHash = user->getPasswordHash(); if (passwordHash.salt.empty() || passwordHash.hash.empty()) { - // hash random stuff here to waste some time + // hash random stuff here to waste some time, don't give clue the user has no password set hashRandomPassword(); return false; } } - return _hashFunc.verify(std::string{ password }, std::string{ passwordHash.salt }, std::string{ passwordHash.hash }); + // Note: the round count set in the actual hash is used to verify, not the one used to construct _hashFunc + bool passwordMatched{ _hashFunc.verify(std::string{ password }, std::string{ passwordHash.salt }, std::string{ passwordHash.hash }) }; + if (passwordMatched && passwordHash.bcryptRoundCount != _bcryptRoundCount) + { + LMS_LOG(AUTH, INFO, "Updating password hash for user '" << loginName << "' to match new bcrypt round count: previously " << passwordHash.bcryptRoundCount << " rounds, now " << _bcryptRoundCount << " rounds"); + const db::User::PasswordHash updatedPasswordHash{ hashPassword(password) }; + + { + db::Session& session{ getDbSession() }; + auto transaction{ session.createWriteTransaction() }; + + if (db::User::pointer user{ db::User::find(session, loginName) }) + user.modify()->setPasswordHash(updatedPasswordHash); + } + } + + return passwordMatched; } bool InternalPasswordService::canSetPasswords() const @@ -96,38 +115,37 @@ namespace lms::auth { const db::User::PasswordHash passwordHash{ hashPassword(newPassword) }; - db::Session& session{ getDbSession() }; - auto transaction{ session.createWriteTransaction() }; - - db::User::pointer user{ db::User::find(session, userId) }; - if (!user) - throw Exception{ "User not found!" }; - - switch (checkPasswordAcceptability(newPassword, PasswordValidationContext{ user->getLoginName(), user->getType() })) { - case PasswordAcceptabilityResult::OK: - break; - case PasswordAcceptabilityResult::TooWeak: - throw PasswordTooWeakException{}; - case PasswordAcceptabilityResult::MustMatchLoginName: - throw PasswordMustMatchLoginNameException{}; - } + db::Session& session{ getDbSession() }; + auto transaction{ session.createWriteTransaction() }; - user.modify()->setPasswordHash(passwordHash); - getAuthTokenService().clearAuthTokens(userId); + db::User::pointer user{ db::User::find(session, userId) }; + if (!user) + throw Exception{ "User not found!" }; + + switch (checkPasswordAcceptability(newPassword, PasswordValidationContext{ user->getLoginName(), user->getType() })) + { + case PasswordAcceptabilityResult::OK: + break; + case PasswordAcceptabilityResult::TooWeak: + throw PasswordTooWeakException{}; + case PasswordAcceptabilityResult::MustMatchLoginName: + throw PasswordMustMatchLoginNameException{}; + } + + user.modify()->setPasswordHash(passwordHash); + } } db::User::PasswordHash InternalPasswordService::hashPassword(std::string_view password) const { const std::string salt{ Wt::WRandom::generateId(32) }; - return { salt, _hashFunc.compute(std::string{ password }, salt) }; + return db::User::PasswordHash{ .bcryptRoundCount = _bcryptRoundCount, .salt = salt, .hash = _hashFunc.compute(std::string{ password }, salt) }; } - void - InternalPasswordService::hashRandomPassword() const + void InternalPasswordService::hashRandomPassword() const { hashPassword(Wt::WRandom::generateId(32)); } - } // namespace lms::auth diff --git a/src/libs/services/auth/impl/internal/InternalPasswordService.hpp b/src/libs/services/auth/impl/internal/InternalPasswordService.hpp index 9c3e07d4..3f37f2a2 100644 --- a/src/libs/services/auth/impl/internal/InternalPasswordService.hpp +++ b/src/libs/services/auth/impl/internal/InternalPasswordService.hpp @@ -24,17 +24,15 @@ #include "database/User.hpp" -#include "LoginThrottler.hpp" #include "PasswordServiceBase.hpp" +#include "services/auth/IPasswordService.hpp" namespace lms::auth { - class IAuthTokenService; - class InternalPasswordService : public PasswordServiceBase { public: - InternalPasswordService(db::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService); + InternalPasswordService(db::Db& db, std::size_t maxThrottlerEntries); private: bool checkUserPassword(std::string_view loginName, std::string_view password) override; @@ -46,8 +44,8 @@ namespace lms::auth db::User::PasswordHash hashPassword(std::string_view password) const; void hashRandomPassword() const; - const Wt::Auth::BCryptHashFunction _hashFunc{ 7 }; // TODO parametrize this + const unsigned _bcryptRoundCount; + const Wt::Auth::BCryptHashFunction _hashFunc{ static_cast(_bcryptRoundCount) }; Wt::Auth::PasswordStrengthValidator _validator; }; - } // namespace lms::auth diff --git a/src/libs/services/auth/include/services/auth/IAuthTokenService.hpp b/src/libs/services/auth/include/services/auth/IAuthTokenService.hpp index f18d5244..e3fea311 100644 --- a/src/libs/services/auth/include/services/auth/IAuthTokenService.hpp +++ b/src/libs/services/auth/include/services/auth/IAuthTokenService.hpp @@ -19,18 +19,21 @@ #pragma once -#include -#include +#include +#include #include #include #include +#include +#include + +#include "core/LiteralString.hpp" #include "database/UserId.hpp" namespace lms::db { class Db; - class User; } // namespace lms::db namespace lms::auth @@ -40,7 +43,15 @@ namespace lms::auth public: virtual ~IAuthTokenService() = default; - // Auth Token services + struct AuthTokenInfo + { + db::UserId userId; + Wt::WDateTime expiry; + Wt::WDateTime lastUsed; // if called by processAuthToken, value is before processing + std::size_t useCount; // if called by processAuthToken, value is before processing + std::optional maxUseCount; + }; + struct AuthTokenProcessResult { enum class State @@ -50,22 +61,25 @@ namespace lms::auth Denied, }; - struct AuthTokenInfo - { - db::UserId userId; - Wt::WDateTime expiry; - }; - State state{ State::Denied }; std::optional authTokenInfo{}; }; - // Provided token is only accepted once - virtual AuthTokenProcessResult processAuthToken(const boost::asio::ip::address& clientAddress, std::string_view tokenValue) = 0; + struct DomainParameters + { + std::optional tokenMaxUseCount; + std::optional tokenDuration; + }; - // Returns a one time token - virtual std::string createAuthToken(db::UserId userid, const Wt::WDateTime& expiry) = 0; - virtual void clearAuthTokens(db::UserId userid) = 0; + virtual void registerDomain(core::LiteralString domain, const DomainParameters& params) = 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; + + virtual void visitAuthTokens(core::LiteralString domain, db::UserId userid, std::function visitor) = 0; + + virtual void createAuthToken(core::LiteralString domain, db::UserId userid, std::string_view token) = 0; + virtual void clearAuthTokens(core::LiteralString domain, db::UserId userid) = 0; }; std::unique_ptr createAuthTokenService(db::Db& db, std::size_t maxThrottlerEntryCount); diff --git a/src/libs/services/auth/include/services/auth/IEnvService.hpp b/src/libs/services/auth/include/services/auth/IEnvService.hpp index 6dd5d85b..64f1a41c 100644 --- a/src/libs/services/auth/include/services/auth/IEnvService.hpp +++ b/src/libs/services/auth/include/services/auth/IEnvService.hpp @@ -58,12 +58,12 @@ namespace lms::auth }; State state{ State::Denied }; - std::optional userId{}; + db::UserId userId{}; }; virtual CheckResult processEnv(const Wt::WEnvironment& env) = 0; virtual CheckResult processRequest(const Wt::Http::Request& request) = 0; }; - std::unique_ptr createEnvService(std::string_view backendName, db::Db& db); + std::unique_ptr createEnvService(std::string_view backend, db::Db& db); } // namespace lms::auth diff --git a/src/libs/services/auth/include/services/auth/IPasswordService.hpp b/src/libs/services/auth/include/services/auth/IPasswordService.hpp index 21b12fab..065fcd50 100644 --- a/src/libs/services/auth/include/services/auth/IPasswordService.hpp +++ b/src/libs/services/auth/include/services/auth/IPasswordService.hpp @@ -37,8 +37,6 @@ namespace lms::db namespace lms::auth { - class IAuthTokenService; - class IPasswordService { public: @@ -53,7 +51,7 @@ namespace lms::auth Throttled, }; State state{ State::Denied }; - std::optional userId{}; + db::UserId userId{}; std::optional expiry{}; }; virtual CheckResult checkUserPassword(const boost::asio::ip::address& clientAddress, @@ -73,5 +71,5 @@ namespace lms::auth virtual void setPassword(db::UserId userId, std::string_view newPassword) = 0; }; - std::unique_ptr createPasswordService(std::string_view authPasswordBackend, db::Db& db, std::size_t maxThrottlerEntryCount, IAuthTokenService& authTokenService); + std::unique_ptr createPasswordService(std::string_view backend, db::Db& db, std::size_t maxThrottlerEntryCount); } // namespace lms::auth diff --git a/src/libs/subsonic/CMakeLists.txt b/src/libs/subsonic/CMakeLists.txt index fdc51c3b..a6ad493c 100644 --- a/src/libs/subsonic/CMakeLists.txt +++ b/src/libs/subsonic/CMakeLists.txt @@ -30,7 +30,6 @@ add_library(lmssubsonic SHARED impl/SubsonicId.cpp impl/SubsonicResource.cpp impl/SubsonicResponse.cpp - impl/Utils.cpp ) target_include_directories(lmssubsonic INTERFACE diff --git a/src/libs/subsonic/impl/ClientInfo.hpp b/src/libs/subsonic/impl/ClientInfo.hpp index 1cca28bb..bd684f47 100644 --- a/src/libs/subsonic/impl/ClientInfo.hpp +++ b/src/libs/subsonic/impl/ClientInfo.hpp @@ -27,10 +27,7 @@ namespace lms::api::subsonic { struct ClientInfo { - std::string ipAddress; std::string name; - std::string user; - std::string password; ProtocolVersion version; }; } // namespace lms::api::subsonic diff --git a/src/libs/subsonic/impl/RequestContext.hpp b/src/libs/subsonic/impl/RequestContext.hpp index e1f6f687..f129a518 100644 --- a/src/libs/subsonic/impl/RequestContext.hpp +++ b/src/libs/subsonic/impl/RequestContext.hpp @@ -41,7 +41,8 @@ namespace lms::api::subsonic { const Wt::Http::ParameterMap& parameters; db::Session& dbSession; - const db::ObjectPtr user; + db::ObjectPtr user; + std::string clientIpAddr; ClientInfo clientInfo; ProtocolVersion serverProtocolVersion; ResponseFormat responseFormat; diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index 6c66409b..455a1fa5 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -33,7 +33,7 @@ #include "database/Db.hpp" #include "database/Session.hpp" #include "database/User.hpp" -#include "services/auth/IEnvService.hpp" +#include "services/auth/IAuthTokenService.hpp" #include "services/auth/IPasswordService.hpp" #include "ParameterParsing.hpp" @@ -41,7 +41,6 @@ #include "RequestContext.hpp" #include "SubsonicId.hpp" #include "SubsonicResponse.hpp" -#include "Utils.hpp" #include "entrypoints/AlbumSongLists.hpp" #include "entrypoints/Bookmarks.hpp" #include "entrypoints/Browsing.hpp" @@ -132,9 +131,10 @@ namespace lms::api::subsonic return res; } - void checkUserTypeIsAllowed(RequestContext& context, core::EnumSet allowedUserTypes) + void checkUserTypeIsAllowed(const db::User::pointer& user, core::EnumSet allowedUserTypes) { - if (!allowedUserTypes.contains(context.user->getType())) + assert(user); + if (!allowedUserTypes.contains(user->getType())) throw UserNotAuthorizedError{}; } @@ -143,20 +143,24 @@ namespace lms::api::subsonic throw NotImplementedGenericError{}; } + enum class AuthenticationMode + { + Authenticated, + Unauthenticated, + }; using RequestHandlerFunc = std::function; - using CheckImplementedFunc = std::function; struct RequestEntryPointInfo { RequestHandlerFunc func; + AuthenticationMode authMode{ AuthenticationMode::Authenticated }; core::EnumSet allowedUserTypes{ db::UserType::DEMO, db::UserType::REGULAR, db::UserType::ADMIN }; - CheckImplementedFunc checkFunc{}; }; const std::unordered_map requestEntryPoints{ // System { "/ping", { handlePingRequest } }, { "/getLicense", { handleGetLicenseRequest } }, - { "/getOpenSubsonicExtensions", { handleGetOpenSubsonicExtensions } }, + { "/getOpenSubsonicExtensions", { handleGetOpenSubsonicExtensions, AuthenticationMode::Unauthenticated } }, // Browsing { "/getMusicFolders", { handleGetMusicFoldersRequest } }, @@ -240,11 +244,11 @@ namespace lms::api::subsonic // User management { "/getUser", { handleGetUserRequest } }, - { "/getUsers", { handleGetUsersRequest, { db::UserType::ADMIN } } }, - { "/createUser", { handleCreateUserRequest, { db::UserType::ADMIN }, &utils::checkSetPasswordImplemented } }, - { "/updateUser", { handleUpdateUserRequest, { db::UserType::ADMIN } } }, - { "/deleteUser", { handleDeleteUserRequest, { db::UserType::ADMIN } } }, - { "/changePassword", { handleChangePassword, { db::UserType::REGULAR, db::UserType::ADMIN }, &utils::checkSetPasswordImplemented } }, + { "/getUsers", { handleGetUsersRequest, AuthenticationMode::Authenticated, { db::UserType::ADMIN } } }, + { "/createUser", { handleNotImplemented } }, + { "/updateUser", { handleNotImplemented } }, + { "/deleteUser", { handleNotImplemented } }, + { "/changePassword", { handleNotImplemented } }, // Bookmarks { "/getBookmarks", { handleGetBookmarks } }, @@ -255,7 +259,7 @@ namespace lms::api::subsonic // Media library scanning { "/getScanStatus", { Scan::handleGetScanStatus } }, - { "/startScan", { Scan::handleStartScan, { db::UserType::ADMIN } } }, + { "/startScan", { Scan::handleStartScan, AuthenticationMode::Authenticated, { db::UserType::ADMIN } } }, }; using MediaRetrievalHandlerFunc = std::function; @@ -278,12 +282,23 @@ namespace lms::api::subsonic TLSMonotonicMemoryResourceCleaner(const TLSMonotonicMemoryResourceCleaner&) = delete; 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::Db& db) : _serverProtocolVersionsByClient{ readConfigProtocolVersions() } , _openSubsonicDisabledClients{ readOpenSubsonicDisabledClients() } , _defaultReleaseCoverClients{ readDefaultCoverClients() } + , _supportUserPasswordAuthentication{ core::Service::get()->getBool("api-subsonic-support-user-password-auth", true) } , _db{ db } { } @@ -317,10 +332,11 @@ namespace lms::api::subsonic { LMS_SCOPED_TRACE_OVERVIEW("Subsonic", itEntryPoint->first); - if (itEntryPoint->second.checkFunc) - itEntryPoint->second.checkFunc(); - - checkUserTypeIsAllowed(requestContext, itEntryPoint->second.allowedUserTypes); + if (itEntryPoint->second.authMode == AuthenticationMode::Authenticated) + { + requestContext.user = getUserFromUserId(_db.getTLSSession(), authenticateUser(request)); + checkUserTypeIsAllowed(requestContext.user, itEntryPoint->second.allowedUserTypes); + } const Response resp{ [&] { LMS_SCOPED_TRACE_DETAILED("Subsonic", "HandleRequest"); @@ -343,11 +359,19 @@ namespace lms::api::subsonic { LMS_SCOPED_TRACE_OVERVIEW("Subsonic", itStreamHandler->first); + // Media retrieval endpoints are always authenticated + // Optim: no need to reauth user for each continuation + if (!request.continuation()) + requestContext.user = getUserFromUserId(_db.getTLSSession(), authenticateUser(request)); + itStreamHandler->second(requestContext, request, response); LMS_LOG(API_SUBSONIC, DEBUG, "Request " << requestId << " '" << requestPath << "' handled!"); return; } + // do not disclose unhandled commands for unauthenticated users + authenticateUser(request); + LMS_LOG(API_SUBSONIC, ERROR, "Unhandled command '" << requestPath << "'"); throw UnknownEntryPointGenericError{}; } @@ -391,16 +415,9 @@ namespace lms::api::subsonic const auto& parameters{ request.getParameterMap() }; ClientInfo res; - if (hasParameter(parameters, "t")) - throw TokenAuthenticationNotSupportedForLDAPUsersError{}; - - res.ipAddress = request.clientAddress(); - // Mandatory parameters res.name = getMandatoryParameterAs(parameters, "c"); res.version = getMandatoryParameterAs(parameters, "v"); - res.user = getMandatoryParameterAs(parameters, "u"); - res.password = decodePasswordIfNeeded(getMandatoryParameterAs(parameters, "p")); return res; } @@ -409,25 +426,15 @@ namespace lms::api::subsonic { const Wt::Http::ParameterMap& parameters{ request.getParameterMap() }; const ClientInfo clientInfo{ getClientInfo(request) }; - const db::UserId userId{ authenticateUser(request, clientInfo) }; bool enableOpenSubsonic{ !_openSubsonicDisabledClients.contains(clientInfo.name) }; bool enableDefaultCover{ _defaultReleaseCoverClients.contains(clientInfo.name) }; const ResponseFormat format{ getParameterAs(request.getParameterMap(), "f").value_or("xml") == "json" ? ResponseFormat::json : ResponseFormat::xml }; - db::User::pointer user; - { - db::Session& session{ _db.getTLSSession() }; - auto transaction{ session.createReadTransaction() }; - - user = db::User::find(session, userId); - if (!user) - throw UserNotAuthorizedError{}; - } - return RequestContext{ .parameters = parameters, .dbSession = _db.getTLSSession(), - .user = user, + .user = db::User::pointer{}, + .clientIpAddr = request.clientAddress(), .clientInfo = clientInfo, .serverProtocolVersion = getServerProtocolVersion(clientInfo.name), .responseFormat = format, @@ -436,46 +443,52 @@ namespace lms::api::subsonic }; } - db::UserId SubsonicResource::authenticateUser(const Wt::Http::Request& request, const ClientInfo& clientInfo) + db::UserId SubsonicResource::authenticateUser(const Wt::Http::Request& request) { - // if the request if a continuation, the user is already authenticated - if (request.continuation()) + const auto& parameters{ request.getParameterMap() }; + + if (hasParameter(parameters, "t")) + throw TokenAuthenticationNotSupportedForLDAPUsersError{}; + + const auto user{ getParameterAs(parameters, "u") }; + const auto password{ getParameterAs(parameters, "p") }; + if (!_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::address::from_string(request.clientAddress()) }; + const std::string authToken{ apiKey ? *apiKey : decodePasswordIfNeeded(*password) }; + + const auto authResult{ core::Service::get()->processAuthToken("subsonic", clientAddress, authToken) }; + switch (authResult.state) { - db::Session& session{ _db.getTLSSession() }; - auto transaction{ session.createReadTransaction() }; - - const auto user{ db::User::find(session, clientInfo.user) }; - if (!user) - throw UserNotAuthorizedError{}; - - return user->getId(); - } - - if (auto* authEnvService{ core::Service::get() }) - { - const auto checkResult{ authEnvService->processRequest(request) }; - if (checkResult.state != auth::IEnvService::CheckResult::State::Granted) - throw UserNotAuthorizedError{}; - - return *checkResult.userId; - } - else if (auto* authPasswordService{ core::Service::get() }) - { - const auto checkResult{ authPasswordService->checkUserPassword(boost::asio::ip::address::from_string(request.clientAddress()), clientInfo.user, clientInfo.password) }; - - switch (checkResult.state) + case auth::IAuthTokenService::AuthTokenProcessResult::State::Granted: + if (user) { - case auth::IPasswordService::CheckResult::State::Granted: - return *checkResult.userId; - break; - case auth::IPasswordService::CheckResult::State::Denied: - throw WrongUsernameOrPasswordError{}; - case auth::IPasswordService::CheckResult::State::Throttled: - throw LoginThrottledGenericError{}; + 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{ "No service available to authenticate user" }; + throw InternalErrorGenericError{ "Cannot authenticate user" }; } - } // namespace lms::api::subsonic diff --git a/src/libs/subsonic/impl/SubsonicResource.hpp b/src/libs/subsonic/impl/SubsonicResource.hpp index 9a00a448..f6bd7417 100644 --- a/src/libs/subsonic/impl/SubsonicResource.hpp +++ b/src/libs/subsonic/impl/SubsonicResource.hpp @@ -50,11 +50,12 @@ namespace lms::api::subsonic static void checkProtocolVersion(ProtocolVersion client, ProtocolVersion server); ClientInfo getClientInfo(const Wt::Http::Request& request); RequestContext buildRequestContext(const Wt::Http::Request& request); - db::UserId authenticateUser(const Wt::Http::Request& request, const ClientInfo& clientInfo); + db::UserId authenticateUser(const Wt::Http::Request& request); const std::unordered_map _serverProtocolVersionsByClient; const std::unordered_set _openSubsonicDisabledClients; const std::unordered_set _defaultReleaseCoverClients; + const bool _supportUserPasswordAuthentication; db::Db& _db; }; diff --git a/src/libs/subsonic/impl/SubsonicResponse.hpp b/src/libs/subsonic/impl/SubsonicResponse.hpp index b8cacdb6..9b106f6f 100644 --- a/src/libs/subsonic/impl/SubsonicResponse.hpp +++ b/src/libs/subsonic/impl/SubsonicResponse.hpp @@ -33,7 +33,7 @@ namespace lms::api::subsonic { // Max count expected from all API methods that expose a count - static inline constexpr std::size_t defaultMaxCountSize{ 1000 }; + static inline constexpr std::size_t defaultMaxCountSize{ 1'000 }; enum class ResponseFormat { @@ -54,6 +54,9 @@ namespace lms::api::subsonic ServerMustUpgrade = 30, WrongUsernameOrPassword = 40, TokenAuthenticationNotSupportedForLDAPUsers = 41, + ProvidedAuthenticationMechanismNotSupported = 42, + MultipleConflictingAuthenticationMechanismsProvided = 43, + InvalidAPIkey = 44, UserNotAuthorized = 50, RequestedDataNotFound = 70, }; @@ -130,6 +133,45 @@ namespace lms::api::subsonic std::string getMessage() const override { return "Token authentication not supported for LDAP users."; } }; + class ProvidedAuthenticationMechanismNotSupportedError : public Error + { + public: + ProvidedAuthenticationMechanismNotSupportedError() + : Error{ Code::ProvidedAuthenticationMechanismNotSupported } {} + + private: + std::string getMessage() const override + { + return "Provided authentication mechanism not supported."; + } + }; + + class MultipleConflictingAuthenticationMechanismsProvidedError : public Error + { + public: + MultipleConflictingAuthenticationMechanismsProvidedError() + : Error{ Code::MultipleConflictingAuthenticationMechanismsProvided } {} + + private: + std::string getMessage() const override + { + return "Multiple conflicting authentication mechanisms provided."; + } + }; + + class InvalidAPIkeyError : public Error + { + public: + InvalidAPIkeyError() + : Error{ Code::InvalidAPIkey } {} + + private: + std::string getMessage() const override + { + return "Invalid API key."; + } + }; + class UserNotAuthorizedError : public Error { public: @@ -153,7 +195,7 @@ namespace lms::api::subsonic class InternalErrorGenericError : public GenericError { public: - InternalErrorGenericError(const std::string& message) + InternalErrorGenericError(std::string_view message) : _message{ message } {} private: @@ -176,26 +218,6 @@ namespace lms::api::subsonic std::string getMessage() const override { return "Unknown API method"; } }; - class PasswordTooWeakGenericError : public GenericError - { - std::string getMessage() const override { return "Password too weak"; } - }; - - class PasswordMustMatchLoginNameGenericError : public GenericError - { - std::string getMessage() const override { return "Password must match login name"; } - }; - - class DemoUserCannotChangePasswordGenericError : public GenericError - { - std::string getMessage() const override { return "Demo user cannot change its password"; } - }; - - class UserAlreadyExistsGenericError : public GenericError - { - std::string getMessage() const override { return "User already exists"; } - }; - class BadParameterGenericError : public GenericError { public: diff --git a/src/libs/subsonic/impl/Utils.cpp b/src/libs/subsonic/impl/Utils.cpp deleted file mode 100644 index f7746a53..00000000 --- a/src/libs/subsonic/impl/Utils.cpp +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (C) 2023 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 "Utils.hpp" - -#include "core/Service.hpp" -#include "core/String.hpp" -#include "services/auth/IPasswordService.hpp" - -#include "SubsonicResponse.hpp" - -namespace lms::api::subsonic::utils -{ - void checkSetPasswordImplemented() - { - auth::IPasswordService* passwordService{ core::Service::get() }; - if (!passwordService || !passwordService->canSetPasswords()) - throw NotImplementedGenericError{}; - } - - std::string makeNameFilesystemCompatible(std::string_view name) - { - return core::stringUtils::replaceInString(name, "/", "_"); - } - -} // namespace lms::api::subsonic::utils \ No newline at end of file diff --git a/src/libs/subsonic/impl/Utils.hpp b/src/libs/subsonic/impl/Utils.hpp deleted file mode 100644 index 4aff2a80..00000000 --- a/src/libs/subsonic/impl/Utils.hpp +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (C) 2023 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 - -namespace lms::api::subsonic::utils -{ - void checkSetPasswordImplemented(); - std::string makeNameFilesystemCompatible(std::string_view name); -} // namespace lms::api::subsonic::utils \ No newline at end of file diff --git a/src/libs/subsonic/impl/entrypoints/Browsing.cpp b/src/libs/subsonic/impl/entrypoints/Browsing.cpp index 370e0b7c..453262ff 100644 --- a/src/libs/subsonic/impl/entrypoints/Browsing.cpp +++ b/src/libs/subsonic/impl/entrypoints/Browsing.cpp @@ -36,7 +36,6 @@ #include "ParameterParsing.hpp" #include "SubsonicId.hpp" -#include "Utils.hpp" #include "responses/Album.hpp" #include "responses/AlbumInfo.hpp" #include "responses/Artist.hpp" diff --git a/src/libs/subsonic/impl/entrypoints/Searching.cpp b/src/libs/subsonic/impl/entrypoints/Searching.cpp index fb9334cf..5a6dac99 100644 --- a/src/libs/subsonic/impl/entrypoints/Searching.cpp +++ b/src/libs/subsonic/impl/entrypoints/Searching.cpp @@ -54,7 +54,7 @@ namespace lms::api::subsonic { std::string clientAddress; std::string clientName; - std::string userName; + UserId user; MediaLibraryId library; std::size_t offset{}; auto operator<=>(const ScanInfo&) const = default; @@ -177,9 +177,9 @@ namespace lms::api::subsonic else { ScanTracker::ScanInfo scanInfo{ - .clientAddress = context.clientInfo.ipAddress, + .clientAddress = context.clientIpAddr, .clientName = context.clientInfo.name, - .userName = context.clientInfo.user, + .user = context.user->getId(), .library = mediaLibrary, .offset = artistOffset }; @@ -241,9 +241,9 @@ namespace lms::api::subsonic else { ScanTracker::ScanInfo scanInfo{ - .clientAddress = context.clientInfo.ipAddress, + .clientAddress = context.clientIpAddr, .clientName = context.clientInfo.name, - .userName = context.clientInfo.user, + .user = context.user->getId(), .library = mediaLibrary, .offset = albumOffset }; @@ -305,9 +305,9 @@ namespace lms::api::subsonic else { ScanTracker::ScanInfo scanInfo{ - .clientAddress = context.clientInfo.ipAddress, + .clientAddress = context.clientIpAddr, .clientName = context.clientInfo.name, - .userName = context.clientInfo.user, + .user = context.user->getId(), .library = mediaLibrary, .offset = songOffset }; diff --git a/src/libs/subsonic/impl/entrypoints/System.cpp b/src/libs/subsonic/impl/entrypoints/System.cpp index 76ad01ca..20df9d75 100644 --- a/src/libs/subsonic/impl/entrypoints/System.cpp +++ b/src/libs/subsonic/impl/entrypoints/System.cpp @@ -41,6 +41,12 @@ namespace lms::api::subsonic songLyricsNode.addArrayValue("versions", 1); } + { + Response::Node& apiKeyAuthentication{ response.createArrayNode("openSubsonicExtensions") }; + apiKeyAuthentication.setAttribute("name", "apiKeyAuthentication"); + apiKeyAuthentication.addArrayValue("versions", 1); + } + return response; }; } // namespace lms::api::subsonic diff --git a/src/libs/subsonic/impl/entrypoints/UserManagement.cpp b/src/libs/subsonic/impl/entrypoints/UserManagement.cpp index 3858f7b1..9f47adaf 100644 --- a/src/libs/subsonic/impl/entrypoints/UserManagement.cpp +++ b/src/libs/subsonic/impl/entrypoints/UserManagement.cpp @@ -6,7 +6,6 @@ #include "services/auth/IPasswordService.hpp" #include "ParameterParsing.hpp" -#include "Utils.hpp" #include "responses/User.hpp" namespace lms::api::subsonic @@ -52,150 +51,4 @@ namespace lms::api::subsonic return response; } - - Response handleCreateUserRequest(RequestContext& context) - { - std::string username{ getMandatoryParameterAs(context.parameters, "username") }; - std::string password{ decodePasswordIfNeeded(getMandatoryParameterAs(context.parameters, "password")) }; - // Just ignore all the other fields as we don't handle them - - db::UserId userId; - { - auto transaction{ context.dbSession.createWriteTransaction() }; - - User::pointer user{ User::find(context.dbSession, username) }; - if (user) - throw UserAlreadyExistsGenericError{}; - - user = context.dbSession.create(username); - userId = user->getId(); - } - - auto removeCreatedUser{ [&] { - auto transaction{ context.dbSession.createWriteTransaction() }; - User::pointer user{ User::find(context.dbSession, userId) }; - if (user) - user.remove(); - } }; - - try - { - core::Service::get()->setPassword(userId, password); - } - catch (const auth::PasswordMustMatchLoginNameException&) - { - removeCreatedUser(); - throw PasswordMustMatchLoginNameGenericError{}; - } - catch (const auth::PasswordTooWeakException&) - { - removeCreatedUser(); - throw PasswordTooWeakGenericError{}; - } - catch (const auth::Exception& exception) - { - removeCreatedUser(); - throw UserNotAuthorizedError{}; - } - - return Response::createOkResponse(context.serverProtocolVersion); - } - - Response handleDeleteUserRequest(RequestContext& context) - { - std::string username{ getMandatoryParameterAs(context.parameters, "username") }; - - auto transaction{ context.dbSession.createWriteTransaction() }; - - User::pointer user{ User::find(context.dbSession, username) }; - if (!user) - throw RequestedDataNotFoundError{}; - - // cannot delete ourself - if (user->getId() == context.user->getId()) - throw UserNotAuthorizedError{}; - - user.remove(); - - return Response::createOkResponse(context.serverProtocolVersion); - } - - Response handleUpdateUserRequest(RequestContext& context) - { - std::string username{ getMandatoryParameterAs(context.parameters, "username") }; - std::optional password{ getParameterAs(context.parameters, "password") }; - - UserId userId; - { - auto transaction{ context.dbSession.createReadTransaction() }; - - User::pointer user{ User::find(context.dbSession, username) }; - if (!user) - throw RequestedDataNotFoundError{}; - - userId = user->getId(); - } - - if (password) - { - utils::checkSetPasswordImplemented(); - - try - { - core::Service()->setPassword(userId, decodePasswordIfNeeded(*password)); - } - catch (const auth::PasswordMustMatchLoginNameException&) - { - throw PasswordMustMatchLoginNameGenericError{}; - } - catch (const auth::PasswordTooWeakException&) - { - throw PasswordTooWeakGenericError{}; - } - catch (const auth::Exception&) - { - throw UserNotAuthorizedError{}; - } - } - - return Response::createOkResponse(context.serverProtocolVersion); - } - - Response handleChangePassword(RequestContext& context) - { - std::string username{ getMandatoryParameterAs(context.parameters, "username") }; - std::string password{ decodePasswordIfNeeded(getMandatoryParameterAs(context.parameters, "password")) }; - - try - { - db::UserId userId; - { - auto transaction{ context.dbSession.createReadTransaction() }; - - checkUserIsMySelfOrAdmin(context, username); - - User::pointer user{ User::find(context.dbSession, username) }; - if (!user) - throw UserNotAuthorizedError{}; - - userId = user->getId(); - } - - core::Service::get()->setPassword(userId, password); - } - catch (const auth::PasswordMustMatchLoginNameException&) - { - throw PasswordMustMatchLoginNameGenericError{}; - } - catch (const auth::PasswordTooWeakException&) - { - throw PasswordTooWeakGenericError{}; - } - catch (const auth::Exception& authException) - { - throw UserNotAuthorizedError{}; - } - - return Response::createOkResponse(context.serverProtocolVersion); - } } // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/Song.cpp b/src/libs/subsonic/impl/responses/Song.cpp index acc4975f..93a60863 100644 --- a/src/libs/subsonic/impl/responses/Song.cpp +++ b/src/libs/subsonic/impl/responses/Song.cpp @@ -38,7 +38,6 @@ #include "RequestContext.hpp" #include "SubsonicId.hpp" -#include "Utils.hpp" #include "responses/Artist.hpp" #include "responses/Contributor.hpp" #include "responses/ItemGenre.hpp" diff --git a/src/lms/main.cpp b/src/lms/main.cpp index 6c349966..c0ce07c3 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -45,6 +45,7 @@ #include "services/scanner/IScannerService.hpp" #include "services/scrobbling/IScrobblingService.hpp" #include "subsonic/SubsonicResource.hpp" +#include "ui/Auth.hpp" #include "ui/LmsApplication.hpp" #include "ui/LmsApplicationManager.hpp" #include "ui/LmsInitApplication.hpp" @@ -79,6 +80,19 @@ namespace lms throw core::LmsException{ "Invalid config value for 'log-min-severity'" }; } + ui::AuthenticationBackend getUIAuthenticationBackend() + { + const std::string backend{ core::stringUtils::stringToLower(core::Service::get()->getString("authentication-backend", "internal")) }; + if (backend == "internal") + return ui::AuthenticationBackend::Internal; + if (backend == "pam") + return ui::AuthenticationBackend::PAM; + if (backend == "http-headers") + return ui::AuthenticationBackend::Env; + + throw core::LmsException{ "Invalid config value for 'authentication-backend'" }; + } + std::optional getTracingLevel() { std::string_view tracingLevel{ core::Service::get()->getString("tracing-level", "disabled") }; @@ -320,24 +334,37 @@ namespace lms ui::LmsApplicationManager appManager; + const std::size_t loginThrottlerMaxEntries{ config->getULong("login-throttler-max-entries", 10'000) }; // Service initialization order is important (reverse-order for deinit) core::Service childProcessManagerService{ core::createChildProcessManager(ioContext) }; - core::Service authTokenService; + + const ui::AuthenticationBackend uiAuthenticationBackend{ getUIAuthenticationBackend() }; + core::Service authTokenService{ auth::createAuthTokenService(database, config->getULong("login-throttler-max-entriees", 10'000)) }; core::Service authPasswordService; core::Service authEnvService; - const std::string authenticationBackend{ core::stringUtils::stringToLower(config->getString("authentication-backend", "internal")) }; - if (authenticationBackend == "internal" || authenticationBackend == "pam") + authTokenService->registerDomain("ui", auth::IAuthTokenService::DomainParameters{ + .tokenMaxUseCount = 1, + .tokenDuration = std::chrono::weeks{ 8 }, + }); + + authTokenService->registerDomain("subsonic", auth::IAuthTokenService::DomainParameters{ + .tokenMaxUseCount = std::nullopt, // no usage limit + .tokenDuration = std::nullopt, // no time limit + }); + + switch (uiAuthenticationBackend) { - authTokenService.assign(auth::createAuthTokenService(database, config->getULong("login-throttler-max-entriees", 10000))); - authPasswordService.assign(auth::createPasswordService(authenticationBackend, database, config->getULong("login-throttler-max-entriees", 10000), *authTokenService.get())); + case ui::AuthenticationBackend::Internal: + authPasswordService.assign(auth::createPasswordService("internal", database, loginThrottlerMaxEntries)); + break; + case ui::AuthenticationBackend::PAM: + authPasswordService.assign(auth::createPasswordService("PAM", database, loginThrottlerMaxEntries)); + break; + case ui::AuthenticationBackend::Env: + authEnvService.assign(auth::createEnvService("http-headers", database)); + break; } - else if (authenticationBackend == "http-headers") - { - authEnvService.assign(auth::createEnvService(authenticationBackend, database)); - } - else - throw core::LmsException{ "Bad value '" + authenticationBackend + "' for 'authentication-backend'" }; image::init(argv[0]); core::Service artworkService{ cover::createArtworkService(database, server.appRoot() + "/images/unknown-cover.svg", server.appRoot() + "/images/unknown-artist.svg") }; @@ -370,8 +397,8 @@ namespace lms // bind UI entry point server.addEntryPoint(Wt::EntryPointType::Application, - [&](const Wt::WEnvironment& env) { - return ui::LmsApplication::create(env, database, appManager); + [&database, &appManager, uiAuthenticationBackend](const Wt::WEnvironment& env) { + return ui::LmsApplication::create(env, database, appManager, uiAuthenticationBackend); }); proxyScannerEventsToApplication(*scannerService, server); diff --git a/src/lms/ui/Auth.cpp b/src/lms/ui/Auth.cpp index 9e744802..eb021da3 100644 --- a/src/lms/ui/Auth.cpp +++ b/src/lms/ui/Auth.cpp @@ -19,6 +19,8 @@ #include "Auth.hpp" +#include + #include #include #include @@ -42,14 +44,18 @@ namespace lms::ui { namespace { + static constexpr core::LiteralString authTokenDomain{ "ui" }; static const std::string authCookieName{ "LmsAuth" }; + static const std::string authCookieSalt{ Wt::Auth::SHA1HashFunction{}.compute(authCookieName, authTokenDomain.c_str()) }; // changing this will invalidate existing tokens void createAuthToken(db::UserId userId, const Wt::WDateTime& expiry) { - const std::string secret{ core::Service::get()->createAuthToken(userId, expiry) }; + const std::string authCookie{ Wt::WRandom::generateId(64) }; + const std::string hashedAuthCookie{ Wt::Auth::SHA1HashFunction{}.compute(authCookie, authCookieSalt) }; + core::Service::get()->createAuthToken(authTokenDomain, userId, hashedAuthCookie); LmsApp->setCookie(authCookieName, - secret, + authCookie, expiry.toTime_t() - Wt::WDateTime::currentDateTime().toTime_t(), "", "", @@ -64,7 +70,8 @@ namespace lms::ui static const Field PasswordField; static const Field RememberMeField; - AuthModel() + AuthModel(auth::IPasswordService& passwordService) + : _passwordService{ passwordService } { addField(LoginNameField); addField(PasswordField); @@ -90,8 +97,7 @@ namespace lms::ui if (Wt::asNumber(value(RememberMeField))) { const Wt::WDateTime now{ Wt::WDateTime::currentDateTime() }; - - createAuthToken(*_userId, isDemo ? now.addDays(3) : now.addYears(1)); + createAuthToken(_userId, isDemo ? now.addDays(3) : now.addYears(1)); } } @@ -101,14 +107,14 @@ namespace lms::ui if (field == PasswordField) { - const auto checkResult{ core::Service::get()->checkUserPassword( + const auto checkResult{ _passwordService.checkUserPassword( boost::asio::ip::address::from_string(LmsApp->environment().clientAddress()), valueText(LoginNameField).toUTF8(), valueText(PasswordField).toUTF8()) }; switch (checkResult.state) { case auth::IPasswordService::CheckResult::State::Granted: - _userId = *checkResult.userId; + _userId = checkResult.userId; break; case auth::IPasswordService::CheckResult::State::Denied: error = Wt::WString::tr("Lms.password-bad-login-combination"); @@ -128,10 +134,11 @@ namespace lms::ui return (validation(field).state() == Wt::ValidationState::Valid); } - std::optional getUserId() const { return _userId; } + db::UserId getUserId() const { return _userId; } private: - std::optional _userId; + db::UserId _userId; + auth::IPasswordService& _passwordService; }; const AuthModel::Field AuthModel::LoginNameField{ "login-name" }; @@ -139,21 +146,24 @@ namespace lms::ui const AuthModel::Field AuthModel::RememberMeField{ "remember-me" }; } // namespace - std::optional processAuthToken(const Wt::WEnvironment& env) + db::UserId processAuthToken(const Wt::WEnvironment& env) { const std::string* authCookie{ env.getCookie(authCookieName) }; if (!authCookie) - return std::nullopt; + return db::UserId{}; - const auto res{ core::Service::get()->processAuthToken(boost::asio::ip::address::from_string(env.clientAddress()), *authCookie) }; + const std::string hashedCookie{ Wt::Auth::SHA1HashFunction{}.compute(*authCookie, authCookieSalt) }; + + const auto res{ core::Service::get()->processAuthToken(authTokenDomain, boost::asio::ip::address::from_string(env.clientAddress()), hashedCookie) }; switch (res.state) { case auth::IAuthTokenService::AuthTokenProcessResult::State::Denied: case auth::IAuthTokenService::AuthTokenProcessResult::State::Throttled: LmsApp->setCookie(authCookieName, std::string{}, 0, "", "", env.urlScheme() == "https"); - return std::nullopt; + return db::UserId{}; case auth::IAuthTokenService::AuthTokenProcessResult::State::Granted: + assert(res.authTokenInfo->maxUseCount && res.authTokenInfo->maxUseCount.value() == 1); // single-use token createAuthToken(res.authTokenInfo->userId, res.authTokenInfo->expiry); break; } @@ -161,10 +171,15 @@ namespace lms::ui return res.authTokenInfo->userId; } - Auth::Auth() + void clearAuthTokens(db::UserId userId) + { + core::Service::get()->clearAuthTokens(authTokenDomain, userId); + } + + PasswordAuth::PasswordAuth(auth::IPasswordService& passwordService) : Wt::WTemplateFormView{ Wt::WString::tr("Lms.Auth.template") } { - auto model{ std::make_shared() }; + auto model{ std::make_shared(passwordService) }; auto processAuth = [this, model] { updateModel(model.get()); @@ -172,7 +187,8 @@ namespace lms::ui if (model->validate()) { model->saveData(); - userLoggedIn.emit(*model->getUserId()); + assert(model->getUserId().isValid()); + userLoggedIn.emit(model->getUserId()); } else updateView(model.get()); diff --git a/src/lms/ui/Auth.hpp b/src/lms/ui/Auth.hpp index b2383620..9763d7fe 100644 --- a/src/lms/ui/Auth.hpp +++ b/src/lms/ui/Auth.hpp @@ -19,20 +19,30 @@ #pragma once -#include - #include #include "database/UserId.hpp" +namespace lms::auth +{ + class IPasswordService; +} + namespace lms::ui { - std::optional processAuthToken(const Wt::WEnvironment& env); + enum class AuthenticationBackend + { + Internal, + Env, + PAM, + }; - class Auth : public Wt::WTemplateFormView + db::UserId processAuthToken(const Wt::WEnvironment& env); + + class PasswordAuth : public Wt::WTemplateFormView { public: - Auth(); + PasswordAuth(auth::IPasswordService& passwordService); Wt::Signal userLoggedIn; }; diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp index 71d9249b..b5aa9e9d 100644 --- a/src/lms/ui/LmsApplication.cpp +++ b/src/lms/ui/LmsApplication.cpp @@ -38,6 +38,7 @@ #include "database/TrackList.hpp" #include "database/User.hpp" #include "services/artwork/IArtworkService.hpp" +#include "services/auth/IAuthTokenService.hpp" #include "services/auth/IEnvService.hpp" #include "services/auth/IPasswordService.hpp" #include "services/scrobbling/IScrobblingService.hpp" @@ -184,22 +185,9 @@ namespace lms::ui } } // namespace - std::unique_ptr LmsApplication::create(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager) + std::unique_ptr LmsApplication::create(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend) { - if (auto* authEnvService{ core::Service::get() }) - { - const auto checkResult{ authEnvService->processEnv(env) }; - if (checkResult.state != auth::IEnvService::CheckResult::State::Granted) - { - LMS_LOG(UI, ERROR, "Cannot authenticate user from environment!"); - // return a blank page - return std::make_unique(env); - } - - return std::make_unique(env, db, appManager, checkResult.userId); - } - - return std::make_unique(env, db, appManager); + return std::make_unique(env, db, appManager, authBackend); } LmsApplication* LmsApplication::instance() @@ -249,17 +237,15 @@ namespace lms::ui return _user->userLoginName; } - LmsApplication::LmsApplication(const Wt::WEnvironment& env, - db::Db& db, - LmsApplicationManager& appManager, - std::optional userId) + LmsApplication::LmsApplication(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend) : Wt::WApplication{ env } , _db{ db } , _appManager{ appManager } + , _authBackend{ authBackend } { try { - init(userId); + init(); } catch (LmsApplicationException& e) { @@ -275,7 +261,7 @@ namespace lms::ui LmsApplication::~LmsApplication() = default; - void LmsApplication::init(std::optional userId) + void LmsApplication::init() { LMS_SCOPED_TRACE_OVERVIEW("UI", "ApplicationInit"); @@ -291,24 +277,38 @@ namespace lms::ui // Handle Media Scanner events and other session events enableUpdates(true); - if (userId) - onUserLoggedIn(*userId, false /* strongAuth */); - else if (core::Service::exists()) + db::UserId userId; + switch (_authBackend) + { + case AuthenticationBackend::Env: + { + const auto checkResult{ core::Service::get()->processEnv(environment()) }; + if (checkResult.state != auth::IEnvService::CheckResult::State::Granted) + { + LMS_LOG(UI, ERROR, "Cannot authenticate user from environment!"); + throw core::LmsException{ "Cannot authenticate user from environment!" }; // Do not put details here at it may appear on the user rendered html + } + assert(checkResult.userId.isValid()); + userId = checkResult.userId; + } + break; + + case AuthenticationBackend::Internal: + [[fallthrough]]; + case AuthenticationBackend::PAM: + // Try to authenticate using auth token ("remember me" checkbox), may fail + userId = processAuthToken(environment()); + break; + } + + if (userId.isValid()) + onUserLoggedIn(userId, false /* strongAuth */); + else processPasswordAuth(); } void LmsApplication::processPasswordAuth() { - { - std::optional userId{ processAuthToken(environment()) }; - if (userId) - { - LMS_LOG(UI, DEBUG, "User authenticated using Auth token!"); - onUserLoggedIn(*userId, false /* strongAuth */); - return; - } - } - // If here is no account in the database, launch the first connection wizard bool firstConnection{}; { @@ -318,17 +318,19 @@ namespace lms::ui LMS_LOG(UI, DEBUG, "Creating root widget. First connection = " << firstConnection); - if (firstConnection && core::Service::get()->canSetPasswords()) + assert(_authBackend == AuthenticationBackend::Internal || _authBackend == AuthenticationBackend::PAM); + auth::IPasswordService& passwordService{ *core::Service::get() }; + + if (firstConnection && _authBackend == AuthenticationBackend::Internal) { - root()->addWidget(std::make_unique()); - } - else - { - Auth* auth{ root()->addNew() }; - auth->userLoggedIn.connect(this, [this](db::UserId userId) { - onUserLoggedIn(userId, true /* strongAuth */); - }); + root()->addNew(passwordService); + return; } + + PasswordAuth* auth{ root()->addNew(passwordService) }; + auth->userLoggedIn.connect(this, [this](db::UserId userId) { + onUserLoggedIn(userId, true /* strongAuth */); + }); } void LmsApplication::finalize() @@ -360,11 +362,7 @@ namespace lms::ui void LmsApplication::logoutUser() { - { - auto transaction{ getDbSession().createWriteTransaction() }; - getUser().modify()->clearAuthTokens(); - } - + core::Service::get()->clearAuthTokens("ui", getUserId()); LMS_LOG(UI, INFO, "User '" << getUserLoginName() << " 'logged out"); goHomeAndQuit(); } diff --git a/src/lms/ui/LmsApplication.hpp b/src/lms/ui/LmsApplication.hpp index 537add0e..5e59a677 100644 --- a/src/lms/ui/LmsApplication.hpp +++ b/src/lms/ui/LmsApplication.hpp @@ -30,6 +30,7 @@ #include "database/UserId.hpp" #include "services/scanner/ScannerEvents.hpp" +#include "Auth.hpp" #include "Notification.hpp" #include "admin/ScannerController.hpp" @@ -53,10 +54,10 @@ namespace lms::ui class LmsApplication : public Wt::WApplication { public: - LmsApplication(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager, std::optional userId = std::nullopt); + LmsApplication(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend); ~LmsApplication(); - static std::unique_ptr create(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager); + static std::unique_ptr create(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend); static LmsApplication* instance(); // Session application data @@ -73,6 +74,8 @@ namespace lms::ui // Proxified scanner events scanner::Events& getScannerEvents() { return _scannerEvents; } + AuthenticationBackend getAuthBackend() const { return _authBackend; } + // Utils void post(std::function func); void setTitle(const Wt::WString& title = ""); @@ -88,7 +91,7 @@ namespace lms::ui Wt::Signal<>& preQuit() { return _preQuit; } private: - void init(std::optional userId); + void init(); void processPasswordAuth(); void handleException(LmsApplicationException& e); void goHomeAndQuit(); @@ -106,6 +109,7 @@ namespace lms::ui db::Db& _db; Wt::Signal<> _preQuit; LmsApplicationManager& _appManager; + const AuthenticationBackend _authBackend; scanner::Events _scannerEvents; struct UserAuthInfo { diff --git a/src/lms/ui/SettingsView.cpp b/src/lms/ui/SettingsView.cpp index 6e3c91ea..73d70f58 100644 --- a/src/lms/ui/SettingsView.cpp +++ b/src/lms/ui/SettingsView.cpp @@ -33,6 +33,7 @@ #include "core/Service.hpp" #include "database/Session.hpp" #include "database/User.hpp" +#include "services/auth/IAuthTokenService.hpp" #include "services/auth/IPasswordService.hpp" #include "LmsApplication.hpp" @@ -59,6 +60,7 @@ namespace lms::ui static inline const Field ReplayGainModeField{ "replaygain-mode" }; static inline const Field ReplayGainPreAmpGainField{ "replaygain-preamp" }; static inline const Field ReplayGainPreAmpGainIfNoInfoField{ "replaygain-preamp-no-rg-info" }; + static inline const Field SubsonicTokenField{ "subsonic-token" }; static inline const Field SubsonicEnableTranscodingByDefault{ "subsonic-enable-transcoding-by-default" }; static inline const Field SubsonicArtistListModeField{ "subsonic-artist-list-mode" }; static inline const Field SubsonicTranscodingOutputFormatField{ "subsonic-transcoding-output-format" }; @@ -76,9 +78,10 @@ namespace lms::ui using FeedbackBackendModel = ValueStringModel; using ScrobblingBackendModel = ValueStringModel; - SettingsModel(auth::IPasswordService* authPasswordService, bool withOldPassword) + SettingsModel(auth::IPasswordService* authPasswordService, bool withOldPassword, auth::IAuthTokenService& authTokenService) : _authPasswordService{ authPasswordService } , _withOldPassword{ withOldPassword } + , _authTokenService{ authTokenService } { initializeModels(); @@ -89,12 +92,15 @@ namespace lms::ui addField(ReplayGainModeField); addField(ReplayGainPreAmpGainField); addField(ReplayGainPreAmpGainIfNoInfoField); + addField(SubsonicTokenField); addField(SubsonicEnableTranscodingByDefault); addField(SubsonicTranscodingOutputBitrateField); addField(SubsonicTranscodingOutputFormatField); addField(FeedbackBackendField); addField(ScrobblingBackendField); addField(ListenBrainzTokenField); + + setValidator(SubsonicTokenField, createUUIDValidator()); setValidator(ListenBrainzTokenField, createUUIDValidator()); if (_authPasswordService) @@ -102,11 +108,11 @@ namespace lms::ui if (_withOldPassword) { addField(PasswordOldField); - setValidator(PasswordOldField, createPasswordCheckValidator()); + setValidator(PasswordOldField, createPasswordCheckValidator(*_authPasswordService)); } addField(PasswordField); - setValidator(PasswordField, createPasswordStrengthValidator([] { return auth::PasswordValidationContext{ .loginName = std::string{ LmsApp->getUserLoginName() }, .userType = LmsApp->getUserType() }; })); + setValidator(PasswordField, createPasswordStrengthValidator(*authPasswordService, [] { return auth::PasswordValidationContext{ .loginName = std::string{ LmsApp->getUserLoginName() }, .userType = LmsApp->getUserType() }; })); addField(PasswordConfirmField); } @@ -173,7 +179,33 @@ namespace lms::ui LmsApp->getMediaPlayer().setSettings(settings); } + // Subsonic API { + const std::string token{ Wt::asString(value(SubsonicTokenField)).toUTF8() }; + + if (token.empty()) + { + _authTokenService.clearAuthTokens("subsonic", user->getId()); + } + else + { + // Consider there must be only one token + bool hasNonMatchingToken{ false }; + bool hasMatchingToken{ false }; + _authTokenService.visitAuthTokens("subsonic", user->getId(), [&](const auth::IAuthTokenService::AuthTokenInfo&, std::string_view storedToken) { + if (storedToken == token) + hasMatchingToken = true; + else + hasNonMatchingToken = true; + }); + + if (!hasMatchingToken || hasNonMatchingToken) + { + _authTokenService.clearAuthTokens("subsonic", user->getId()); + _authTokenService.createAuthToken("subsonic", user->getId(), token); + } + } + bool subsonicEnableTranscodingByDefault{ Wt::asNumber(value(SubsonicEnableTranscodingByDefault)) != 0 }; user.modify()->setSubsonicEnableTranscodingByDefault(subsonicEnableTranscodingByDefault); @@ -207,6 +239,7 @@ namespace lms::ui if (_authPasswordService && !valueText(PasswordField).empty()) { _authPasswordService->setPassword(user->getId(), valueText(PasswordField).toUTF8()); + _authTokenService.clearAuthTokens("ui", user->getId()); } } @@ -214,7 +247,7 @@ namespace lms::ui { auto transaction{ LmsApp->getDbSession().createReadTransaction() }; - User::pointer user{ LmsApp->getUser() }; + const User::pointer user{ LmsApp->getUser() }; { auto artistReleaseSortMethodRow{ _artistReleaseSortMethodModel->getRowFromValue(user->getUIArtistReleaseSortMethod()) }; @@ -251,7 +284,14 @@ namespace lms::ui setValue(ReplayGainPreAmpGainIfNoInfoField, settings.replayGain.preAmpGainIfNoInfo); } + // Subsonic { + // Consider there is only one auth token + _authTokenService.visitAuthTokens("subsonic", user->getId(), [&](const auth::IAuthTokenService::AuthTokenInfo&, std::string_view storedToken) { + if (Wt::asString(value(SubsonicTokenField)).empty()) + setValue(SubsonicTokenField, Wt::WString::fromUTF8(std::string{ storedToken })); + }); + setValue(SubsonicEnableTranscodingByDefault, user->getSubsonicEnableTranscodingByDefault()); auto subsonicTranscodingOutputBitrateRow{ _transcodingOutputBitrateModel->getRowFromValue(user->getSubsonicDefaultTranscodingOutputBitrate()) }; @@ -380,6 +420,8 @@ namespace lms::ui auth::IPasswordService* _authPasswordService{}; bool _withOldPassword{}; + auth::IAuthTokenService& _authTokenService; + std::shared_ptr _artistReleaseSortMethodModel; std::shared_ptr _transcodingModeModeModel; std::shared_ptr> _transcodingOutputBitrateModel; @@ -403,8 +445,7 @@ namespace lms::ui refreshView(); } - void - SettingsView::refreshView() + void SettingsView::refreshView() { if (!wApp->internalPathMatches("/settings")) return; @@ -417,12 +458,14 @@ namespace lms::ui auto t{ addNew(Wt::WString::tr("Lms.Settings.template")) }; - auto* authPasswordService{ core::Service::get() }; - if (authPasswordService && !authPasswordService->canSetPasswords()) - authPasswordService = nullptr; - - auto model{ std::make_shared(authPasswordService, !LmsApp->isUserAuthStrong()) }; + auth::IPasswordService* authPasswordService{}; + if (LmsApp->getAuthBackend() == AuthenticationBackend::Internal) + { + authPasswordService = core::Service::get(); + assert(authPasswordService->canSetPasswords()); + } + auto model{ std::make_shared(authPasswordService, !LmsApp->isUserAuthStrong(), *core::Service::get()) }; if (authPasswordService) { t->setCondition("if-has-change-password", true); @@ -514,6 +557,31 @@ namespace lms::ui // Subsonic { t->setCondition("if-has-subsonic-api", core::Service::get()->getBool("api-subsonic", true)); + t->setCondition("if-has-subsonic-token-usage", core::Service::get()->getBool("api-subsonic-support-user-password-auth", true)); + + auto subsonicToken{ std::make_unique() }; + Wt::WLineEdit* subsonicTokenPtr{ subsonicToken.get() }; + subsonicTokenPtr->setEchoMode(Wt::EchoMode::Password); + subsonicTokenPtr->setReadOnly(true); + t->setFormWidget(SettingsModel::SubsonicTokenField, std::move(subsonicToken)); + + auto subsonicTokenRegenBtn{ std::make_unique(Wt::WString::tr("Lms.Settings.regen-token")) }; + subsonicTokenRegenBtn->clicked().connect(this, [subsonicTokenPtr] { + subsonicTokenPtr->setValueText(Wt::WString::fromUTF8(std::string{ core::UUID::generate().getAsString() })); + }); + t->bindWidget("subsonic-token-regen-btn", std::move(subsonicTokenRegenBtn)); + + auto subsonicTokenVisibilityBtn{ std::make_unique(Wt::WString::tr("Lms.template.toggle-visibility-btn"), Wt::TextFormat::XHTML) }; + subsonicTokenVisibilityBtn->clicked().connect(this, [subsonicTokenPtr] { + subsonicTokenPtr->setEchoMode(subsonicTokenPtr->echoMode() == Wt::EchoMode::Password ? Wt::EchoMode::Normal : Wt::EchoMode::Password); + }); + t->bindWidget("subsonic-token-visibility-btn", std::move(subsonicTokenVisibilityBtn)); + + auto subsonicTokenDelBtn{ std::make_unique(Wt::WString::tr("Lms.template.trash-btn"), Wt::TextFormat::XHTML) }; + subsonicTokenDelBtn->clicked().connect(this, [subsonicTokenPtr] { + subsonicTokenPtr->setValueText(""); + }); + t->bindWidget("subsonic-token-del-btn", std::move(subsonicTokenDelBtn)); // Enable transcoding by default t->setFormWidget(SettingsModel::SubsonicEnableTranscodingByDefault, std::make_unique()); @@ -555,7 +623,15 @@ namespace lms::ui // Backend settings { auto listenbrainzToken{ std::make_unique() }; + Wt::WLineEdit* listenbrainzTokenPtr{ listenbrainzToken.get() }; + listenbrainzTokenPtr->setEchoMode(Wt::EchoMode::Password); t->setFormWidget(SettingsModel::ListenBrainzTokenField, std::move(listenbrainzToken)); + + auto listenbrainzTokenVisibilityBtn{ std::make_unique(Wt::WString::tr("Lms.template.toggle-visibility-btn"), Wt::TextFormat::XHTML) }; + listenbrainzTokenVisibilityBtn->clicked().connect(this, [listenbrainzTokenPtr] { + listenbrainzTokenPtr->setEchoMode(listenbrainzTokenPtr->echoMode() == Wt::EchoMode::Password ? Wt::EchoMode::Normal : Wt::EchoMode::Password); + }); + t->bindWidget("listenbrainz-token-visibility-btn", std::move(listenbrainzTokenVisibilityBtn)); } auto updateListenBrainzTokenField{ [=] { diff --git a/src/lms/ui/admin/InitWizardView.cpp b/src/lms/ui/admin/InitWizardView.cpp index 0a4ca1eb..3cec9844 100644 --- a/src/lms/ui/admin/InitWizardView.cpp +++ b/src/lms/ui/admin/InitWizardView.cpp @@ -47,15 +47,15 @@ namespace lms::ui static inline const Field PasswordField{ "password" }; static inline const Field PasswordConfirmField{ "password-confirm" }; - InitWizardModel() - : Wt::WFormModel() + InitWizardModel(auth::IPasswordService& passwordService) + : _passwordService{ passwordService } { addField(AdminLoginField); addField(PasswordField); addField(PasswordConfirmField); setValidator(AdminLoginField, createLoginNameValidator()); - setValidator(PasswordField, createPasswordStrengthValidator([this] { return auth::PasswordValidationContext{ valueText(AdminLoginField).toUTF8(), db::UserType::ADMIN }; })); + setValidator(PasswordField, createPasswordStrengthValidator(passwordService, [this] { return auth::PasswordValidationContext{ valueText(AdminLoginField).toUTF8(), db::UserType::ADMIN }; })); validator(PasswordField)->setMandatory(true); setValidator(PasswordConfirmField, createMandatoryValidator()); } @@ -71,7 +71,7 @@ namespace lms::ui db::User::pointer user{ LmsApp->getDbSession().create(valueText(AdminLoginField).toUTF8()) }; user.modify()->setType(db::UserType::ADMIN); - core::Service::get()->setPassword(user->getId(), valueText(PasswordField).toUTF8()); + _passwordService.setPassword(user->getId(), valueText(PasswordField).toUTF8()); } bool validateField(Field field) @@ -97,12 +97,15 @@ namespace lms::ui return false; } + + private: + auth::IPasswordService& _passwordService; }; - InitWizardView::InitWizardView() + InitWizardView::InitWizardView(auth::IPasswordService& passwordService) : Wt::WTemplateFormView{ Wt::WString::tr("Lms.Admin.InitWizard.template") } { - auto model = std::make_shared(); + auto model = std::make_shared(passwordService); // AdminLogin { diff --git a/src/lms/ui/admin/InitWizardView.hpp b/src/lms/ui/admin/InitWizardView.hpp index dd416ea0..318704fb 100644 --- a/src/lms/ui/admin/InitWizardView.hpp +++ b/src/lms/ui/admin/InitWizardView.hpp @@ -21,13 +21,17 @@ #include +namespace lms::auth +{ + class IPasswordService; +} + namespace lms::ui { - class InitWizardView : public Wt::WTemplateFormView { public: - InitWizardView(); + InitWizardView(auth::IPasswordService& passwordService); }; } // namespace lms::ui diff --git a/src/lms/ui/admin/UserView.cpp b/src/lms/ui/admin/UserView.cpp index cfb15b81..123ef5ea 100644 --- a/src/lms/ui/admin/UserView.cpp +++ b/src/lms/ui/admin/UserView.cpp @@ -34,6 +34,7 @@ #include "core/String.hpp" #include "database/Session.hpp" #include "database/User.hpp" +#include "services/auth/IAuthTokenService.hpp" #include "services/auth/IPasswordService.hpp" #include "LmsApplication.hpp" @@ -52,9 +53,10 @@ namespace lms::ui static inline const Field PasswordField{ "password" }; static inline const Field DemoField{ "demo" }; - UserModel(std::optional userId, auth::IPasswordService* authPasswordService) + UserModel(std::optional userId, auth::IPasswordService* authPasswordService, auth::IAuthTokenService& authTokenService) : _userId{ userId } , _authPasswordService{ authPasswordService } + , _authTokenService{ authTokenService } { if (!_userId) { @@ -65,7 +67,7 @@ namespace lms::ui if (authPasswordService) { addField(PasswordField); - setValidator(PasswordField, createPasswordStrengthValidator([this] { return auth::PasswordValidationContext{ getLoginName(), getUserType() }; })); + setValidator(PasswordField, createPasswordStrengthValidator(*authPasswordService, [this] { return auth::PasswordValidationContext{ getLoginName(), getUserType() }; })); if (!userId) validator(PasswordField)->setMandatory(true); } @@ -86,7 +88,10 @@ namespace lms::ui throw UserNotFoundException{}; if (_authPasswordService && !valueText(PasswordField).empty()) + { _authPasswordService->setPassword(user->getId(), valueText(PasswordField).toUTF8()); + _authTokenService.clearAuthTokens("ui", user->getId()); + } } else { @@ -103,6 +108,9 @@ namespace lms::ui if (_authPasswordService) _authPasswordService->setPassword(user->getId(), valueText(PasswordField).toUTF8()); + + // For demo user, we create the subsonic API auth token now as we have no other mean to create it later + core::Service::get()->createAuthToken("subsonic", user->getId(), core::UUID::generate().getAsString()); } } @@ -177,6 +185,7 @@ namespace lms::ui std::optional _userId; auth::IPasswordService* _authPasswordService{}; + auth::IAuthTokenService& _authTokenService; }; UserView::UserView() @@ -199,12 +208,14 @@ namespace lms::ui Wt::WTemplateFormView* t{ addNew(Wt::WString::tr("Lms.Admin.User.template")) }; - auto* authPasswordService{ core::Service::get() }; - if (authPasswordService && !authPasswordService->canSetPasswords()) - authPasswordService = nullptr; - - auto model{ std::make_shared(userId, authPasswordService) }; + auth::IPasswordService* authPasswordService{}; + if (LmsApp->getAuthBackend() == AuthenticationBackend::Internal) + { + authPasswordService = core::Service::get(); + assert(authPasswordService->canSetPasswords()); + } + auto model{ std::make_shared(userId, authPasswordService, *core::Service::get()) }; if (userId) { auto transaction{ LmsApp->getDbSession().createReadTransaction() }; diff --git a/src/lms/ui/admin/UsersView.cpp b/src/lms/ui/admin/UsersView.cpp index 7ad07c99..65d08173 100644 --- a/src/lms/ui/admin/UsersView.cpp +++ b/src/lms/ui/admin/UsersView.cpp @@ -43,7 +43,7 @@ namespace lms::ui _container = bindNew("users"); - if (core::Service::get() && core::Service::get()->canSetPasswords()) + if (LmsApp->getAuthBackend() == AuthenticationBackend::Internal) { setCondition("if-can-create-user", true); diff --git a/src/lms/ui/common/PasswordValidator.cpp b/src/lms/ui/common/PasswordValidator.cpp index f6cb31d6..0c22c793 100644 --- a/src/lms/ui/common/PasswordValidator.cpp +++ b/src/lms/ui/common/PasswordValidator.cpp @@ -33,8 +33,9 @@ namespace lms::ui class PasswordStrengthValidator : public Wt::WValidator { public: - PasswordStrengthValidator(PasswordValidationContextGetFunc passwordValidationContextGetFunc) + PasswordStrengthValidator(const auth::IPasswordService& passwordService, PasswordValidationContextGetFunc passwordValidationContextGetFunc) : _passwordValidationContextGetFunc{ std::move(passwordValidationContextGetFunc) } + , _passwordService{ passwordService } { } @@ -43,6 +44,7 @@ namespace lms::ui std::string javaScriptValidate() const override { return {}; } PasswordValidationContextGetFunc _passwordValidationContextGetFunc; + const auth::IPasswordService& _passwordService; }; } // namespace @@ -53,7 +55,7 @@ namespace lms::ui const auth::PasswordValidationContext context{ _passwordValidationContextGetFunc() }; - switch (core::Service::get()->checkPasswordAcceptability(input.toUTF8(), context)) + switch (_passwordService.checkPasswordAcceptability(input.toUTF8(), context)) { case auth::IPasswordService::PasswordAcceptabilityResult::OK: return Wt::WValidator::Result{ Wt::ValidationState::Valid }; @@ -66,16 +68,24 @@ namespace lms::ui throw core::LmsException{ "internal error" }; } - std::unique_ptr createPasswordStrengthValidator(PasswordValidationContextGetFunc passwordValidationContextGetFunc) + std::unique_ptr createPasswordStrengthValidator(const auth::IPasswordService& passwordService, PasswordValidationContextGetFunc passwordValidationContextGetFunc) { - return std::make_unique(std::move(passwordValidationContextGetFunc)); + return std::make_unique(passwordService, std::move(passwordValidationContextGetFunc)); } class PasswordCheckValidator : public Wt::WValidator { + public: + PasswordCheckValidator(auth::IPasswordService& passwordService) + : _passwordService{ passwordService } + { + } + private: Wt::WValidator::Result validate(const Wt::WString& input) const override; std::string javaScriptValidate() const override { return {}; } + + auth::IPasswordService& _passwordService; }; Wt::WValidator::Result PasswordCheckValidator::validate(const Wt::WString& input) const @@ -83,7 +93,7 @@ namespace lms::ui if (input.empty()) return Wt::WValidator::validate(input); - const auto checkResult{ core::Service::get()->checkUserPassword( + const auto checkResult{ _passwordService.checkUserPassword( boost::asio::ip::address::from_string(LmsApp->environment().clientAddress()), LmsApp->getUserLoginName(), input.toUTF8()) }; @@ -100,9 +110,9 @@ namespace lms::ui throw core::LmsException{ "InternalError" }; } - std::unique_ptr createPasswordCheckValidator() + std::unique_ptr createPasswordCheckValidator(auth::IPasswordService& passwordService) { - return std::make_unique(); + return std::make_unique(passwordService); } } // namespace lms::ui \ No newline at end of file diff --git a/src/lms/ui/common/PasswordValidator.hpp b/src/lms/ui/common/PasswordValidator.hpp index adeeebbe..8135716c 100644 --- a/src/lms/ui/common/PasswordValidator.hpp +++ b/src/lms/ui/common/PasswordValidator.hpp @@ -25,11 +25,16 @@ #include "services/auth/Types.hpp" +namespace lms::auth +{ + class IPasswordService; +} + namespace lms::ui { using PasswordValidationContextGetFunc = std::function; - std::unique_ptr createPasswordStrengthValidator(PasswordValidationContextGetFunc passwordValidationContextGetFunc); + std::unique_ptr createPasswordStrengthValidator(const auth::IPasswordService& passwordService, PasswordValidationContextGetFunc passwordValidationContextGetFunc); // Check current user password - std::unique_ptr createPasswordCheckValidator(); + std::unique_ptr createPasswordCheckValidator(auth::IPasswordService& passwordService); } // namespace lms::ui