Merge branch 'subsonic-new-auth-system' into develop
This commit is contained in:
+10
-3
@@ -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/)
|
||||
|
||||
+10
-7
@@ -289,7 +289,7 @@
|
||||
<!--Settings-->
|
||||
<message id="Lms.Settings.artist-release-sort-method">Method to sort artist's albums</message>
|
||||
<message id="Lms.Settings.audio">Audio</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">These audio settings are local to your browser!</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">These audio settings are local to your browser</message>
|
||||
<message id="Lms.Settings.backend.internal">Internal</message>
|
||||
<message id="Lms.Settings.backend.listenbrainz">ListenBrainz</message>
|
||||
<message id="Lms.Settings.backend.listenbrainz-token">ListenBrainz API token</message>
|
||||
@@ -307,6 +307,7 @@
|
||||
<message id="Lms.Settings.original-date-desc">Original Release Date (Descending)</message>
|
||||
<message id="Lms.Settings.password-bad">Bad password</message>
|
||||
<message id="Lms.Settings.password-must-fill-old-password">Old password must be filled in</message>
|
||||
<message id="Lms.Settings.regen-token">Generate</message>
|
||||
<message id="Lms.Settings.replaygain-mode">ReplayGain mode</message>
|
||||
<message id="Lms.Settings.replaygain-mode.none">No ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.auto">Auto</message>
|
||||
@@ -317,13 +318,19 @@
|
||||
<message id="Lms.Settings.scrobbling">Scrobbling</message>
|
||||
<message id="Lms.Settings.services">Services</message>
|
||||
<message id="Lms.Settings.settings">Settings</message>
|
||||
<message id="Lms.Settings.settings-saved">New settings saved!</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode">Artist list mode</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.all-artists">All artists</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.release-artists">Album artists</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.track-artists">Track artists</message>
|
||||
<message id="Lms.Settings.subsonic-api">Subsonic API</message>
|
||||
<message id="Lms.Settings.user-interface">User Interface</message>
|
||||
<message id="Lms.Settings.subsonic-token">OpenSubsonic API key</message>
|
||||
<message id="Lms.Settings.subsonic-token-usage">Use this API key as the password for clients that do not support the 'API Key Authentication' extension</message>
|
||||
<message id="Lms.Settings.transcoding">Transcoding</message>
|
||||
<message id="Lms.Settings.transcoding-mode">Enable transcoding</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">Always</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Only when format is not supported by the browser</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">Never</message>
|
||||
<message id="Lms.Settings.transcoding-output-bitrate">Transcoding output bitrate</message>
|
||||
<message id="Lms.Settings.transcoding-output-format">Transcoding output format</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.matroska_opus">Matroska/Opus</message>
|
||||
@@ -331,10 +338,6 @@
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_opus">Ogg/Opus</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_vorbis">Ogg/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.webm_vorbis">WebM/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-mode">Enable transcoding</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">Always</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Only when format is not supported by the browser</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">Never</message>
|
||||
<message id="Lms.Settings.settings-saved">New settings saved!</message>
|
||||
<message id="Lms.Settings.user-interface">User Interface</message>
|
||||
|
||||
</messages>
|
||||
|
||||
+10
-7
@@ -289,7 +289,7 @@
|
||||
<!--Settings-->
|
||||
<message id="Lms.Settings.artist-release-sort-method">Méthode pour trier les albums de l'artiste</message>
|
||||
<message id="Lms.Settings.audio">Audio</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">Ces paramètres audio sont locaux à votre navigateur !</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">Ces paramètres audio sont locaux à votre navigateur</message>
|
||||
<message id="Lms.Settings.backend.internal">Interne</message>
|
||||
<message id="Lms.Settings.backend.listenbrainz">ListenBrainz</message>
|
||||
<message id="Lms.Settings.backend.listenbrainz-token">Jeton d'API ListenBrainz</message>
|
||||
@@ -307,6 +307,7 @@
|
||||
<message id="Lms.Settings.original-date-desc">Date de sortie originale (Décroissant)</message>
|
||||
<message id="Lms.Settings.password-bad">Mauvais mot de passe</message>
|
||||
<message id="Lms.Settings.password-must-fill-old-password">L'ancien mot de passe doit être renseigné</message>
|
||||
<message id="Lms.Settings.regen-token">Regénérer</message>
|
||||
<message id="Lms.Settings.replaygain-mode">Mode ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.none">Pas de ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.auto">Automatique</message>
|
||||
@@ -317,13 +318,19 @@
|
||||
<message id="Lms.Settings.scrobbling">Scrobbling</message>
|
||||
<message id="Lms.Settings.services">Services</message>
|
||||
<message id="Lms.Settings.settings">Paramètres</message>
|
||||
<message id="Lms.Settings.settings-saved">Paramètres sauvegardés !</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode">Mode de listage des artistes</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.all-artists">Tous les artistes</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.release-artists">Tous les artistes d'album</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.track-artists">Tous les artistes de piste</message>
|
||||
<message id="Lms.Settings.subsonic-api">API Subsonic</message>
|
||||
<message id="Lms.Settings.user-interface">Interface utilisateur</message>
|
||||
<message id="Lms.Settings.subsonic-token">Clef pour l'API OpenSubsonic</message>
|
||||
<message id="Lms.Settings.subsonic-token-usage">Utilisez cette clé d'API comme mot de passe pour les clients qui ne prennent pas en charge l'extension 'API Key Authentication'</message>
|
||||
<message id="Lms.Settings.transcoding">Transcodage</message>
|
||||
<message id="Lms.Settings.transcoding-mode">Appliquer le transcodage</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">Toujours</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Seulement lorsque le format n'est pas pris en charge par votre navigateur</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">Jamais</message>
|
||||
<message id="Lms.Settings.transcoding-output-bitrate">Bitrate du transcodage</message>
|
||||
<message id="Lms.Settings.transcoding-output-format">Format du transcodage</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.matroska_opus">Matroska/Opus</message>
|
||||
@@ -331,11 +338,7 @@
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_opus">Ogg/Opus</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_vorbis">Ogg/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.webm_vorbis">WebM/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-mode">Appliquer le transcodage</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">Toujours</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Seulement lorsque le format n'est pas pris en charge par votre navigateur</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">Jamais</message>
|
||||
<message id="Lms.Settings.settings-saved">Paramètres sauvegardés !</message>
|
||||
<message id="Lms.Settings.user-interface">Interface utilisateur</message>
|
||||
|
||||
<!--Wt-->
|
||||
<message id="Wt.WDateTime.LessThanASecond">moins d'une seconde</message>
|
||||
|
||||
+12
-9
@@ -46,7 +46,7 @@
|
||||
<message id="Lms.Error.error-occurred">C'è stato un errore!</message>
|
||||
<message id="Lms.Error.go-home">Ritorna alla home</message>
|
||||
<message id="Lms.Error.release-not-found">Album non trovato</message>
|
||||
|
||||
<message id="Lms.Error.tracklist-not-found">Playlist non trovata</message>
|
||||
<message id="Lms.Error.user-not-allowed">Non sei autorizzato ad eseguire questa operazione</message>
|
||||
<message id="Lms.Error.user-not-found">Utente non trovato</message>
|
||||
|
||||
@@ -289,7 +289,7 @@
|
||||
<!--Settings-->
|
||||
<message id="Lms.Settings.artist-release-sort-method">Metodo per ordinare gli album dell'artista</message>
|
||||
<message id="Lms.Settings.audio">Audio</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">Queste impostazioni sono salvate localmente su questo dispositivo!</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">Queste impostazioni sono salvate localmente su questo dispositivo</message>
|
||||
<message id="Lms.Settings.backend.internal">Interno</message>
|
||||
<message id="Lms.Settings.backend.listenbrainz">ListenBrainz</message>
|
||||
<message id="Lms.Settings.backend.listenbrainz-token">Token API ListenBrainz</message>
|
||||
@@ -307,6 +307,7 @@
|
||||
<message id="Lms.Settings.original-date-desc">Data di uscita originale (Decrescente)</message>
|
||||
<message id="Lms.Settings.password-bad">Password errata</message>
|
||||
<message id="Lms.Settings.password-must-fill-old-password">Devi inserire la password attuale</message>
|
||||
<message id="Lms.Settings.regen-token">Genera</message>
|
||||
<message id="Lms.Settings.replaygain-mode">Modalità ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.none">No ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.auto">Automatica</message>
|
||||
@@ -317,13 +318,19 @@
|
||||
<message id="Lms.Settings.scrobbling">Scrobbling</message>
|
||||
<message id="Lms.Settings.services">Servizi</message>
|
||||
<message id="Lms.Settings.settings">Impostazioni</message>
|
||||
<message id="Lms.Settings.settings-saved">Nuove impostazioni salvate!</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode">Modalità di elencazione artisti</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.all-artists">Tutti gli artisti</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.release-artists">Artisti album</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.track-artists">Artisti tracce</message>
|
||||
<message id="Lms.Settings.subsonic-api">Subsonic API</message>
|
||||
<message id="Lms.Settings.user-interface">Interfaccia utente</message>
|
||||
<message id="Lms.Settings.subsonic-api">API Subsonic</message>
|
||||
<message id="Lms.Settings.subsonic-token">Chiave API OpenSubsonic</message>
|
||||
<message id="Lms.Settings.subsonic-token-usage">Utilizza questa chiave API come password per i client che non supportano l'estensione 'Autenticazione con chiave API'</message>
|
||||
<message id="Lms.Settings.transcoding">Transcodifica</message>
|
||||
<message id="Lms.Settings.transcoding-mode">Abilita transcodifica</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">Sempre</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Quando il formato non è supportato</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">Mai</message>
|
||||
<message id="Lms.Settings.transcoding-output-bitrate">Bitrate transcodifica</message>
|
||||
<message id="Lms.Settings.transcoding-output-format">Formato di transcodifica</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.matroska_opus">Matroska/Opus</message>
|
||||
@@ -331,11 +338,7 @@
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_opus">Ogg/Opus</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_vorbis">Ogg/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.webm_vorbis">WebM/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-mode">Abilita transcodifica</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">Sempre</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Quando il formato non è supportato</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">Mai</message>
|
||||
<message id="Lms.Settings.settings-saved">Nuove impostazioni salvate!</message>
|
||||
<message id="Lms.Settings.user-interface">Interfaccia utente</message>
|
||||
|
||||
<!--Wt-->
|
||||
<message id="Wt.WDateTime.LessThanASecond">meno di un secondo</message>
|
||||
|
||||
+11
-8
@@ -315,7 +315,7 @@
|
||||
<!--Settings-->
|
||||
<message id="Lms.Settings.artist-release-sort-method">Metoda sortowania albumów artysty</message>
|
||||
<message id="Lms.Settings.audio">Dźwięk</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">Te ustawienia dotyczą wyłącznie tej przeglądarki!</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">Te ustawienia dotyczą wyłącznie tej przeglądarki</message>
|
||||
<message id="Lms.Settings.backend.internal">Wewnętrzny</message>
|
||||
<message id="Lms.Settings.backend.listenbrainz">ListenBrainz</message>
|
||||
<message id="Lms.Settings.backend.listenbrainz-token">ListenBrainz API token</message>
|
||||
@@ -333,6 +333,7 @@
|
||||
<message id="Lms.Settings.original-date-desc">Oryginalna data wydania (Malejąco)</message>
|
||||
<message id="Lms.Settings.password-bad">Błędne hasło</message>
|
||||
<message id="Lms.Settings.password-must-fill-old-password">Należy podać stare hasło</message>
|
||||
<message id="Lms.Settings.regen-token">Generuj</message>
|
||||
<message id="Lms.Settings.replaygain-mode">Tryb ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.none">Bez ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.auto">Auto</message>
|
||||
@@ -343,13 +344,19 @@
|
||||
<message id="Lms.Settings.scrobbling">Scrobbling</message>
|
||||
<message id="Lms.Settings.services">Usługi</message>
|
||||
<message id="Lms.Settings.settings">Ustawienia</message>
|
||||
<message id="Lms.Settings.settings-saved">Ustawienia zapisane!</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode">Tryb listy artystów</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.all-artists">Wszyscy artyści</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.release-artists">Artyści z albumów</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.track-artists">Artyści ze ścieżek</message>
|
||||
<message id="Lms.Settings.subsonic-api">Subsonic API</message>
|
||||
<message id="Lms.Settings.user-interface">Interfejs użytkownika</message>
|
||||
<message id="Lms.Settings.subsonic-api">API Subsonic</message>
|
||||
<message id="Lms.Settings.subsonic-token">Klucz API OpenSubsonic</message>
|
||||
<message id="Lms.Settings.subsonic-token-usage">Użyj tego klucza API jako hasła dla klientów, którzy nie obsługują rozszerzenia 'Uwierzytelnianie za pomocą klucza API'</message>
|
||||
<message id="Lms.Settings.transcoding">Transkodowanie</message>
|
||||
<message id="Lms.Settings.transcoding-mode">Aktywuj transkodowanie</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">Zawsze</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Tylko gdy przeglądarka nie wspiera formatu</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">Nigdy</message>
|
||||
<message id="Lms.Settings.transcoding-output-bitrate">Przepływność transkodowania</message>
|
||||
<message id="Lms.Settings.transcoding-output-format">Format wyjściowy transkodowania</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.matroska_opus">Matroska/Opus</message>
|
||||
@@ -357,11 +364,7 @@
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_opus">Ogg/Opus</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_vorbis">Ogg/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.webm_vorbis">WebM/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-mode">Aktywuj transkodowanie</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">Zawsze</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">Tylko gdy przeglądarka nie wspiera formatu</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">Nigdy</message>
|
||||
<message id="Lms.Settings.settings-saved">Ustawienia zapisane!</message>
|
||||
<message id="Lms.Settings.user-interface">Interfejs użytkownika</message>
|
||||
|
||||
<!--Wt-->
|
||||
<message id="Wt.WDateTime.LessThanASecond">mniej niż sekunda</message>
|
||||
|
||||
@@ -289,7 +289,7 @@
|
||||
<!--Settings-->
|
||||
|
||||
<message id="Lms.Settings.audio">音频</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">这些音频设置仅保存在您的本地浏览器!</message>
|
||||
<message id="Lms.Settings.audio-settings-are-local">这些音频设置仅保存在您的本地浏览器</message>
|
||||
|
||||
|
||||
|
||||
@@ -307,6 +307,7 @@
|
||||
|
||||
<message id="Lms.Settings.password-bad">无效密码</message>
|
||||
<message id="Lms.Settings.password-must-fill-old-password">必须填写旧密码</message>
|
||||
|
||||
<message id="Lms.Settings.replaygain-mode">ReplayGain 模式</message>
|
||||
<message id="Lms.Settings.replaygain-mode.none">没有 ReplayGain</message>
|
||||
<message id="Lms.Settings.replaygain-mode.auto">自动模式</message>
|
||||
@@ -317,13 +318,19 @@
|
||||
|
||||
|
||||
|
||||
<message id="Lms.Settings.settings-saved">新设置已保存!</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode">歌手列表模式</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.all-artists">所有歌手</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.release-artists">专辑歌手</message>
|
||||
<message id="Lms.Settings.subsonic-artist-list-mode.track-artists">音轨歌手</message>
|
||||
<message id="Lms.Settings.subsonic-api">Subsonic 应用程序接口</message>
|
||||
|
||||
|
||||
<message id="Lms.Settings.transcoding">转码</message>
|
||||
<message id="Lms.Settings.transcoding-mode">启动转码</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">总是</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">仅当格式不支持时使用</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">从不</message>
|
||||
<message id="Lms.Settings.transcoding-output-bitrate">转码比特率</message>
|
||||
<message id="Lms.Settings.transcoding-output-format">转码格式</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.matroska_opus">Matroska/Opus</message>
|
||||
@@ -331,11 +338,6 @@
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_opus">Ogg/Opus</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.ogg_vorbis">Ogg/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-output-format.webm_vorbis">WebM/Vorbis</message>
|
||||
<message id="Lms.Settings.transcoding-mode">启动转码</message>
|
||||
<message id="Lms.Settings.transcoding-mode.always">总是</message>
|
||||
<message id="Lms.Settings.transcoding-mode.if-format-not-supported">仅当格式不支持时使用</message>
|
||||
<message id="Lms.Settings.transcoding-mode.never">从不</message>
|
||||
<message id="Lms.Settings.settings-saved">新设置已保存!</message>
|
||||
|
||||
<!--Wt-->
|
||||
<message id="Wt.WMessageBox.Yes">是</message>
|
||||
|
||||
@@ -19,5 +19,6 @@
|
||||
<message id="Lms.template.edit-btn"><i class="fa fa-fw fa-edit"></i></message>
|
||||
<message id="Lms.template.more-btn"><i class="fa fa-fw fa-ellipsis-v"></i></message>
|
||||
<message id="Lms.template.play-btn"><i class="fa fa-fw fa-play"></i></message>
|
||||
<message id="Lms.template.toggle-visibility-btn"><i class="fa fa-fw fa-eye"></i></message>
|
||||
|
||||
</messages>
|
||||
|
||||
+25
-3
@@ -85,6 +85,25 @@
|
||||
</div>
|
||||
${<if-has-subsonic-api>}
|
||||
<legend>${tr:Lms.Settings.subsonic-api}</legend>
|
||||
<div class="col-lg-12">
|
||||
<label class="form-label" for="${id:subsonic-token}">
|
||||
${tr:Lms.Settings.subsonic-token}
|
||||
${<if-has-subsonic-token-usage>}
|
||||
<button type="button" class="btn btn-sm p-0" data-bs-toggle="tooltip" data-bs-placement="right" title="${tr:Lms.Settings.subsonic-token-usage}">
|
||||
<i class="fa fa-fw fa-info-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
${</if-has-subsonic-token-usage>}
|
||||
</label>
|
||||
<div class="input-group mb-3">
|
||||
${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"}
|
||||
<div class="invalid-feedback">
|
||||
${subsonic-token-info class="help-block"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check">
|
||||
${subsonic-enable-transcoding-by-default class="form-check-input"}
|
||||
@@ -150,9 +169,12 @@
|
||||
<label class="form-label" for="${id:listenbrainz-token}">
|
||||
${tr:Lms.Settings.backend.listenbrainz-token}
|
||||
</label>
|
||||
${listenbrainz-token class="form-control"}
|
||||
<div class="invalid-feedback">
|
||||
${listenbrainz-token-info class="help-block"}
|
||||
<div class="input-group mb-3">
|
||||
${listenbrainz-token class="form-control"}
|
||||
${listenbrainz-token-visibility-btn class="btn btn-outline-secondary"}
|
||||
<div class="invalid-feedback">
|
||||
${listenbrainz-token-info class="help-block"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${<if-has-change-password>}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
|
||||
namespace lms::core
|
||||
{
|
||||
template<typename Class>
|
||||
// Tag can be used if you have multiple services sharing the same interface
|
||||
template<typename Class, typename Tag = Class>
|
||||
class Service
|
||||
{
|
||||
public:
|
||||
@@ -46,12 +47,12 @@ namespace lms::core
|
||||
|
||||
Class* operator->() const
|
||||
{
|
||||
return Service<Class>::get();
|
||||
return Service<Class, Tag>::get();
|
||||
}
|
||||
|
||||
Class& operator*() const
|
||||
{
|
||||
return *Service<Class>::get();
|
||||
return *Service<Class, Tag>::get();
|
||||
}
|
||||
|
||||
static Class* get() { return _service.get(); }
|
||||
|
||||
@@ -5,6 +5,7 @@ add_executable(test-core
|
||||
LiteralString.cpp
|
||||
Path.cpp
|
||||
RecursiveSharedMutex.cpp
|
||||
Service.cpp
|
||||
String.cpp
|
||||
TraceLogger.cpp
|
||||
Utils.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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#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<IMyService>().exists());
|
||||
EXPECT_EQ(Service<IMyService>().get(), nullptr);
|
||||
|
||||
Service<IMyService> myService{ std::make_unique<MyService>() };
|
||||
|
||||
EXPECT_TRUE(Service<IMyService>().exists());
|
||||
EXPECT_EQ(Service<IMyService>().get(), myService.get());
|
||||
}
|
||||
|
||||
TEST(Service, tags)
|
||||
{
|
||||
Service<IMyService, MyServiceTag> myService{ std::make_unique<MyService>() };
|
||||
Service<IMyService, MyOtherServiceTag> myOtherService{ std::make_unique<MyOtherService>() };
|
||||
|
||||
EXPECT_FALSE(Service<IMyService>().exists());
|
||||
EXPECT_EQ(Service<IMyService>().get(), nullptr);
|
||||
|
||||
EXPECT_TRUE((Service<IMyService, MyServiceTag>().exists()));
|
||||
EXPECT_TRUE((Service<IMyService, MyOtherServiceTag>().exists()));
|
||||
EXPECT_EQ((Service<IMyService, MyServiceTag>().get()), myService.get());
|
||||
EXPECT_EQ((Service<IMyService, MyOtherServiceTag>().get()), myOtherService.get());
|
||||
}
|
||||
} // namespace lms::core::tests
|
||||
@@ -30,29 +30,65 @@
|
||||
|
||||
namespace lms::db
|
||||
{
|
||||
AuthToken::AuthToken(std::string_view value, const Wt::WDateTime& expiry, ObjectPtr<User> user)
|
||||
: _value{ value }
|
||||
AuthToken::AuthToken(std::string_view domain, std::string_view value, const Wt::WDateTime& expiry, std::optional<long> maxUseCount, ObjectPtr<User> 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> user)
|
||||
AuthToken::pointer AuthToken::create(Session& session, std::string_view domain, std::string_view value, const Wt::WDateTime& expiry, std::optional<long> maxUseCount, ObjectPtr<User> user)
|
||||
{
|
||||
return session.getDboSession()->add(std::unique_ptr<AuthToken>{ new AuthToken{ value, expiry, user } });
|
||||
return session.getDboSession()->add(std::unique_ptr<AuthToken>{ 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<AuthToken>().where("value = ?").bind(value));
|
||||
return utils::fetchQuerySingleResult(session.getDboSession()->query<int>("SELECT COUNT(*) FROM auth_token"));
|
||||
}
|
||||
|
||||
AuthToken::pointer AuthToken::find(Session& session, AuthTokenId id)
|
||||
{
|
||||
return utils::fetchQuerySingleResult(session.getDboSession()->query<Wt::Dbo::ptr<AuthToken>>("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<AuthToken>() };
|
||||
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<void(const AuthToken::pointer&)> visitor)
|
||||
{
|
||||
session.checkReadTransaction();
|
||||
|
||||
auto query{ session.getDboSession()->find<AuthToken>() };
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -97,10 +97,4 @@ namespace lms::db
|
||||
assert(isAudioBitrateAllowed(bitrate));
|
||||
_subsonicDefaultTranscodingOutputBitrate = bitrate;
|
||||
}
|
||||
|
||||
void User::clearAuthTokens()
|
||||
{
|
||||
_authTokens.clear();
|
||||
}
|
||||
|
||||
} // namespace lms::db
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
#include <Wt/Dbo/Dbo.h>
|
||||
@@ -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<AuthToken, AuthTokenId>
|
||||
{
|
||||
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<void(const AuthToken::pointer&)> 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<User> 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<std::size_t> getMaxUseCount() const { return _maxUseCount; }
|
||||
|
||||
// Setters
|
||||
std::size_t incUseCount() { return ++_useCount; }
|
||||
void setLastUsed(const Wt::WDateTime& lastUsed) { _lastUsed = lastUsed; }
|
||||
|
||||
template<class Action>
|
||||
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> user);
|
||||
static pointer create(Session& session, std::string_view value, const Wt::WDateTime& expiry, ObjectPtr<User> user);
|
||||
AuthToken(std::string_view domain, std::string_view value, const Wt::WDateTime& expiry, std::optional<long> maxUseCount, ObjectPtr<User> user);
|
||||
static pointer create(Session& session, std::string_view domain, std::string_view value, const Wt::WDateTime& expiry, std::optional<long> maxUseCount, ObjectPtr<User> user);
|
||||
|
||||
std::string _domain;
|
||||
std::string _value;
|
||||
Wt::WDateTime _expiry;
|
||||
long _useCount{};
|
||||
Wt::WDateTime _lastUsed;
|
||||
std::optional<long> _maxUseCount;
|
||||
Wt::Dbo::ptr<User> _user;
|
||||
};
|
||||
} // namespace lms::db
|
||||
} // namespace lms::db
|
||||
@@ -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<std::size_t>(_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;
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "Common.hpp"
|
||||
|
||||
#include "database/AuthToken.hpp"
|
||||
|
||||
namespace lms::db::tests
|
||||
{
|
||||
using ScopedAuthToken = ScopedEntity<db::AuthToken>;
|
||||
|
||||
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
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
add_executable(test-database
|
||||
AuthToken.cpp
|
||||
Artist.cpp
|
||||
Cluster.cpp
|
||||
Common.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{}));
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
#include "AuthTokenService.hpp"
|
||||
|
||||
#include <Wt/Auth/HashFunction.h>
|
||||
#include <Wt/Auth/PasswordStrengthValidator.h>
|
||||
#include <Wt/WRandom.h>
|
||||
|
||||
#include "core/Exception.hpp"
|
||||
@@ -32,72 +31,95 @@
|
||||
|
||||
namespace lms::auth
|
||||
{
|
||||
|
||||
std::unique_ptr<IAuthTokenService> createAuthTokenService(db::Db& db, std::size_t maxThrottlerEntries)
|
||||
namespace
|
||||
{
|
||||
return std::make_unique<AuthTokenService>(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<IAuthTokenService> createAuthTokenService(db::Db& db, std::size_t maxThrottlerEntryCount)
|
||||
{
|
||||
return std::make_unique<AuthTokenService>(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<db::AuthToken>(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::AuthTokenProcessResult::AuthTokenInfo>
|
||||
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<std::chrono::seconds>(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<db::AuthToken>(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()) : "<unset>"));
|
||||
|
||||
// TODO per domain
|
||||
if (user->getAuthTokensCount() >= 50)
|
||||
db::AuthToken::removeExpiredTokens(session, domain.str(), Wt::WDateTime::currentDateTime());
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<AuthTokenService::AuthTokenInfo> 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<void(const AuthTokenInfo& info, std::string_view token)> 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
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <shared_mutex>
|
||||
|
||||
#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<void(const AuthTokenInfo& info, std::string_view token)> 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<AuthTokenService::AuthTokenProcessResult::AuthTokenInfo> processAuthToken(std::string_view secret);
|
||||
std::optional<AuthTokenInfo> processAuthToken(core::LiteralString domain, std::string_view tokenValue);
|
||||
const DomainParameters& getDomainParameters(core::LiteralString domain) const;
|
||||
|
||||
std::shared_mutex _mutex;
|
||||
std::map<core::LiteralString, DomainParameters> _domainParameters;
|
||||
LoginThrottler _loginThrottler;
|
||||
};
|
||||
} // namespace lms::auth
|
||||
|
||||
@@ -37,28 +37,24 @@ namespace lms::auth
|
||||
{
|
||||
static const Wt::Auth::SHA1HashFunction sha1Function;
|
||||
|
||||
std::unique_ptr<IPasswordService>
|
||||
createPasswordService(std::string_view passwordAuthenticationBackend, db::Db& db, std::size_t maxThrottlerEntries, IAuthTokenService& authTokenService)
|
||||
std::unique_ptr<IPasswordService> createPasswordService(std::string_view backend, db::Db& db, std::size_t maxThrottlerEntryCount)
|
||||
{
|
||||
if (passwordAuthenticationBackend == "internal")
|
||||
return std::make_unique<InternalPasswordService>(db, maxThrottlerEntries, authTokenService);
|
||||
if (backend == "internal")
|
||||
return std::make_unique<InternalPasswordService>(db, maxThrottlerEntryCount);
|
||||
#ifdef LMS_SUPPORT_PAM
|
||||
else if (passwordAuthenticationBackend == "pam")
|
||||
return std::make_unique<PAMPasswordService>(db, maxThrottlerEntries, authTokenService);
|
||||
if (backend == "PAM")
|
||||
return std::make_unique<PAMPasswordService>(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 << "'");
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,18 +21,21 @@
|
||||
|
||||
#include <Wt/WRandom.h>
|
||||
|
||||
#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<unsigned>(core::Service<core::IConfig>::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
|
||||
|
||||
@@ -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<int>(_bcryptRoundCount) };
|
||||
Wt::Auth::PasswordStrengthValidator _validator;
|
||||
};
|
||||
|
||||
} // namespace lms::auth
|
||||
|
||||
@@ -19,18 +19,21 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <Wt/WDateTime.h>
|
||||
#include <boost/asio/ip/address.hpp>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include <Wt/WDateTime.h>
|
||||
#include <boost/asio/ip/address.hpp>
|
||||
|
||||
#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<std::size_t> 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> 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<std::size_t> tokenMaxUseCount;
|
||||
std::optional<std::chrono::seconds> 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<void(const AuthTokenInfo& info, std::string_view token)> 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<IAuthTokenService> createAuthTokenService(db::Db& db, std::size_t maxThrottlerEntryCount);
|
||||
|
||||
@@ -58,12 +58,12 @@ namespace lms::auth
|
||||
};
|
||||
|
||||
State state{ State::Denied };
|
||||
std::optional<db::UserId> userId{};
|
||||
db::UserId userId{};
|
||||
};
|
||||
|
||||
virtual CheckResult processEnv(const Wt::WEnvironment& env) = 0;
|
||||
virtual CheckResult processRequest(const Wt::Http::Request& request) = 0;
|
||||
};
|
||||
|
||||
std::unique_ptr<IEnvService> createEnvService(std::string_view backendName, db::Db& db);
|
||||
std::unique_ptr<IEnvService> createEnvService(std::string_view backend, db::Db& db);
|
||||
} // namespace lms::auth
|
||||
|
||||
@@ -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<db::UserId> userId{};
|
||||
db::UserId userId{};
|
||||
std::optional<Wt::WDateTime> 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<IPasswordService> createPasswordService(std::string_view authPasswordBackend, db::Db& db, std::size_t maxThrottlerEntryCount, IAuthTokenService& authTokenService);
|
||||
std::unique_ptr<IPasswordService> createPasswordService(std::string_view backend, db::Db& db, std::size_t maxThrottlerEntryCount);
|
||||
} // namespace lms::auth
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,7 +41,8 @@ namespace lms::api::subsonic
|
||||
{
|
||||
const Wt::Http::ParameterMap& parameters;
|
||||
db::Session& dbSession;
|
||||
const db::ObjectPtr<db::User> user;
|
||||
db::ObjectPtr<db::User> user;
|
||||
std::string clientIpAddr;
|
||||
ClientInfo clientInfo;
|
||||
ProtocolVersion serverProtocolVersion;
|
||||
ResponseFormat responseFormat;
|
||||
|
||||
@@ -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<db::UserType> allowedUserTypes)
|
||||
void checkUserTypeIsAllowed(const db::User::pointer& user, core::EnumSet<db::UserType> 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<Response(RequestContext& context)>;
|
||||
using CheckImplementedFunc = std::function<void()>;
|
||||
struct RequestEntryPointInfo
|
||||
{
|
||||
RequestHandlerFunc func;
|
||||
AuthenticationMode authMode{ AuthenticationMode::Authenticated };
|
||||
core::EnumSet<db::UserType> allowedUserTypes{ db::UserType::DEMO, db::UserType::REGULAR, db::UserType::ADMIN };
|
||||
CheckImplementedFunc checkFunc{};
|
||||
};
|
||||
|
||||
const std::unordered_map<core::LiteralString, RequestEntryPointInfo, core::LiteralStringHash, core::LiteralStringEqual> 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<void(RequestContext&, const Wt::Http::Request&, Wt::Http::Response&)>;
|
||||
@@ -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<core::IConfig>::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<std::string>(parameters, "c");
|
||||
res.version = getMandatoryParameterAs<ProtocolVersion>(parameters, "v");
|
||||
res.user = getMandatoryParameterAs<std::string>(parameters, "u");
|
||||
res.password = decodePasswordIfNeeded(getMandatoryParameterAs<std::string>(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<std::string>(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<std::string>(parameters, "u") };
|
||||
const auto password{ getParameterAs<std::string>(parameters, "p") };
|
||||
if (!_supportUserPasswordAuthentication && (password || user))
|
||||
throw ProvidedAuthenticationMechanismNotSupportedError{};
|
||||
|
||||
const auto apiKey{ getParameterAs<std::string>(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<auth::IAuthTokenService>::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<auth::IEnvService>::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<auth::IPasswordService>::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
|
||||
|
||||
@@ -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<std::string, ProtocolVersion> _serverProtocolVersionsByClient;
|
||||
const std::unordered_set<std::string> _openSubsonicDisabledClients;
|
||||
const std::unordered_set<std::string> _defaultReleaseCoverClients;
|
||||
const bool _supportUserPasswordAuthentication;
|
||||
|
||||
db::Db& _db;
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#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<auth::IPasswordService>::get() };
|
||||
if (!passwordService || !passwordService->canSetPasswords())
|
||||
throw NotImplementedGenericError{};
|
||||
}
|
||||
|
||||
std::string makeNameFilesystemCompatible(std::string_view name)
|
||||
{
|
||||
return core::stringUtils::replaceInString(name, "/", "_");
|
||||
}
|
||||
|
||||
} // namespace lms::api::subsonic::utils
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace lms::api::subsonic::utils
|
||||
{
|
||||
void checkSetPasswordImplemented();
|
||||
std::string makeNameFilesystemCompatible(std::string_view name);
|
||||
} // namespace lms::api::subsonic::utils
|
||||
@@ -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"
|
||||
|
||||
@@ -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<ArtistId>::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<ReleaseId>::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<TrackId>::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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<std::string>(context.parameters, "username") };
|
||||
std::string password{ decodePasswordIfNeeded(getMandatoryParameterAs<std::string>(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<User>(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<auth::IPasswordService>::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<std::string>(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<std::string>(context.parameters, "username") };
|
||||
std::optional<std::string> password{ getParameterAs<std::string>(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<auth::IPasswordService>()->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<std::string>(context.parameters, "username") };
|
||||
std::string password{ decodePasswordIfNeeded(getMandatoryParameterAs<std::string>(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<auth::IPasswordService>::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
|
||||
@@ -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"
|
||||
|
||||
+40
-13
@@ -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<core::IConfig>::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<core::tracing::Level> getTracingLevel()
|
||||
{
|
||||
std::string_view tracingLevel{ core::Service<core::IConfig>::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<core::IChildProcessManager> childProcessManagerService{ core::createChildProcessManager(ioContext) };
|
||||
core::Service<auth::IAuthTokenService> authTokenService;
|
||||
|
||||
const ui::AuthenticationBackend uiAuthenticationBackend{ getUIAuthenticationBackend() };
|
||||
core::Service<auth::IAuthTokenService> authTokenService{ auth::createAuthTokenService(database, config->getULong("login-throttler-max-entriees", 10'000)) };
|
||||
core::Service<auth::IPasswordService> authPasswordService;
|
||||
core::Service<auth::IEnvService> 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<cover::IArtworkService> 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);
|
||||
|
||||
+32
-16
@@ -19,6 +19,8 @@
|
||||
|
||||
#include "Auth.hpp"
|
||||
|
||||
#include <Wt/Auth/HashFunction.h>
|
||||
|
||||
#include <Wt/WCheckBox.h>
|
||||
#include <Wt/WEnvironment.h>
|
||||
#include <Wt/WFormModel.h>
|
||||
@@ -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<auth::IAuthTokenService>::get()->createAuthToken(userId, expiry) };
|
||||
const std::string authCookie{ Wt::WRandom::generateId(64) };
|
||||
const std::string hashedAuthCookie{ Wt::Auth::SHA1HashFunction{}.compute(authCookie, authCookieSalt) };
|
||||
core::Service<auth::IAuthTokenService>::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<auth::IPasswordService>::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<db::UserId> getUserId() const { return _userId; }
|
||||
db::UserId getUserId() const { return _userId; }
|
||||
|
||||
private:
|
||||
std::optional<db::UserId> _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<db::UserId> 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<auth::IAuthTokenService>::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<auth::IAuthTokenService>::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<auth::IAuthTokenService>::get()->clearAuthTokens(authTokenDomain, userId);
|
||||
}
|
||||
|
||||
PasswordAuth::PasswordAuth(auth::IPasswordService& passwordService)
|
||||
: Wt::WTemplateFormView{ Wt::WString::tr("Lms.Auth.template") }
|
||||
{
|
||||
auto model{ std::make_shared<AuthModel>() };
|
||||
auto model{ std::make_shared<AuthModel>(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());
|
||||
|
||||
+15
-5
@@ -19,20 +19,30 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <Wt/WTemplateFormView.h>
|
||||
|
||||
#include "database/UserId.hpp"
|
||||
|
||||
namespace lms::auth
|
||||
{
|
||||
class IPasswordService;
|
||||
}
|
||||
|
||||
namespace lms::ui
|
||||
{
|
||||
std::optional<db::UserId> 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<db::UserId> userLoggedIn;
|
||||
};
|
||||
|
||||
@@ -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<Wt::WApplication> LmsApplication::create(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager)
|
||||
std::unique_ptr<Wt::WApplication> LmsApplication::create(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend)
|
||||
{
|
||||
if (auto* authEnvService{ core::Service<auth::IEnvService>::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<Wt::WApplication>(env);
|
||||
}
|
||||
|
||||
return std::make_unique<LmsApplication>(env, db, appManager, checkResult.userId);
|
||||
}
|
||||
|
||||
return std::make_unique<LmsApplication>(env, db, appManager);
|
||||
return std::make_unique<LmsApplication>(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<db::UserId> 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<db::UserId> 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<auth::IPasswordService>::exists())
|
||||
db::UserId userId;
|
||||
switch (_authBackend)
|
||||
{
|
||||
case AuthenticationBackend::Env:
|
||||
{
|
||||
const auto checkResult{ core::Service<auth::IEnvService>::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<db::UserId> 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<auth::IPasswordService>::get()->canSetPasswords())
|
||||
assert(_authBackend == AuthenticationBackend::Internal || _authBackend == AuthenticationBackend::PAM);
|
||||
auth::IPasswordService& passwordService{ *core::Service<auth::IPasswordService>::get() };
|
||||
|
||||
if (firstConnection && _authBackend == AuthenticationBackend::Internal)
|
||||
{
|
||||
root()->addWidget(std::make_unique<InitWizardView>());
|
||||
}
|
||||
else
|
||||
{
|
||||
Auth* auth{ root()->addNew<Auth>() };
|
||||
auth->userLoggedIn.connect(this, [this](db::UserId userId) {
|
||||
onUserLoggedIn(userId, true /* strongAuth */);
|
||||
});
|
||||
root()->addNew<InitWizardView>(passwordService);
|
||||
return;
|
||||
}
|
||||
|
||||
PasswordAuth* auth{ root()->addNew<PasswordAuth>(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<auth::IAuthTokenService>::get()->clearAuthTokens("ui", getUserId());
|
||||
LMS_LOG(UI, INFO, "User '" << getUserLoginName() << " 'logged out");
|
||||
goHomeAndQuit();
|
||||
}
|
||||
|
||||
@@ -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<db::UserId> userId = std::nullopt);
|
||||
LmsApplication(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager, AuthenticationBackend authBackend);
|
||||
~LmsApplication();
|
||||
|
||||
static std::unique_ptr<Wt::WApplication> create(const Wt::WEnvironment& env, db::Db& db, LmsApplicationManager& appManager);
|
||||
static std::unique_ptr<Wt::WApplication> 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<void()> func);
|
||||
void setTitle(const Wt::WString& title = "");
|
||||
@@ -88,7 +91,7 @@ namespace lms::ui
|
||||
Wt::Signal<>& preQuit() { return _preQuit; }
|
||||
|
||||
private:
|
||||
void init(std::optional<db::UserId> 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
|
||||
{
|
||||
|
||||
+87
-11
@@ -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<db::FeedbackBackend>;
|
||||
using ScrobblingBackendModel = ValueStringModel<db::ScrobblingBackend>;
|
||||
|
||||
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> _artistReleaseSortMethodModel;
|
||||
std::shared_ptr<TranscodingModeModel> _transcodingModeModeModel;
|
||||
std::shared_ptr<ValueStringModel<Bitrate>> _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::WTemplateFormView>(Wt::WString::tr("Lms.Settings.template")) };
|
||||
|
||||
auto* authPasswordService{ core::Service<auth::IPasswordService>::get() };
|
||||
if (authPasswordService && !authPasswordService->canSetPasswords())
|
||||
authPasswordService = nullptr;
|
||||
|
||||
auto model{ std::make_shared<SettingsModel>(authPasswordService, !LmsApp->isUserAuthStrong()) };
|
||||
auth::IPasswordService* authPasswordService{};
|
||||
if (LmsApp->getAuthBackend() == AuthenticationBackend::Internal)
|
||||
{
|
||||
authPasswordService = core::Service<auth::IPasswordService>::get();
|
||||
assert(authPasswordService->canSetPasswords());
|
||||
}
|
||||
|
||||
auto model{ std::make_shared<SettingsModel>(authPasswordService, !LmsApp->isUserAuthStrong(), *core::Service<auth::IAuthTokenService>::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<core::IConfig>::get()->getBool("api-subsonic", true));
|
||||
t->setCondition("if-has-subsonic-token-usage", core::Service<core::IConfig>::get()->getBool("api-subsonic-support-user-password-auth", true));
|
||||
|
||||
auto subsonicToken{ std::make_unique<Wt::WLineEdit>() };
|
||||
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::WPushButton>(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::WPushButton>(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::WPushButton>(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<Wt::WCheckBox>());
|
||||
@@ -555,7 +623,15 @@ namespace lms::ui
|
||||
// Backend settings
|
||||
{
|
||||
auto listenbrainzToken{ std::make_unique<Wt::WLineEdit>() };
|
||||
Wt::WLineEdit* listenbrainzTokenPtr{ listenbrainzToken.get() };
|
||||
listenbrainzTokenPtr->setEchoMode(Wt::EchoMode::Password);
|
||||
t->setFormWidget(SettingsModel::ListenBrainzTokenField, std::move(listenbrainzToken));
|
||||
|
||||
auto listenbrainzTokenVisibilityBtn{ std::make_unique<Wt::WPushButton>(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{ [=] {
|
||||
|
||||
@@ -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<db::User>(valueText(AdminLoginField).toUTF8()) };
|
||||
user.modify()->setType(db::UserType::ADMIN);
|
||||
core::Service<auth::IPasswordService>::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<InitWizardModel>();
|
||||
auto model = std::make_shared<InitWizardModel>(passwordService);
|
||||
|
||||
// AdminLogin
|
||||
{
|
||||
|
||||
@@ -21,13 +21,17 @@
|
||||
|
||||
#include <Wt/WTemplateFormView.h>
|
||||
|
||||
namespace lms::auth
|
||||
{
|
||||
class IPasswordService;
|
||||
}
|
||||
|
||||
namespace lms::ui
|
||||
{
|
||||
|
||||
class InitWizardView : public Wt::WTemplateFormView
|
||||
{
|
||||
public:
|
||||
InitWizardView();
|
||||
InitWizardView(auth::IPasswordService& passwordService);
|
||||
};
|
||||
|
||||
} // namespace lms::ui
|
||||
|
||||
@@ -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> userId, auth::IPasswordService* authPasswordService)
|
||||
UserModel(std::optional<UserId> 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<auth::IAuthTokenService>::get()->createAuthToken("subsonic", user->getId(), core::UUID::generate().getAsString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +185,7 @@ namespace lms::ui
|
||||
|
||||
std::optional<UserId> _userId;
|
||||
auth::IPasswordService* _authPasswordService{};
|
||||
auth::IAuthTokenService& _authTokenService;
|
||||
};
|
||||
|
||||
UserView::UserView()
|
||||
@@ -199,12 +208,14 @@ namespace lms::ui
|
||||
|
||||
Wt::WTemplateFormView* t{ addNew<Wt::WTemplateFormView>(Wt::WString::tr("Lms.Admin.User.template")) };
|
||||
|
||||
auto* authPasswordService{ core::Service<auth::IPasswordService>::get() };
|
||||
if (authPasswordService && !authPasswordService->canSetPasswords())
|
||||
authPasswordService = nullptr;
|
||||
|
||||
auto model{ std::make_shared<UserModel>(userId, authPasswordService) };
|
||||
auth::IPasswordService* authPasswordService{};
|
||||
if (LmsApp->getAuthBackend() == AuthenticationBackend::Internal)
|
||||
{
|
||||
authPasswordService = core::Service<auth::IPasswordService>::get();
|
||||
assert(authPasswordService->canSetPasswords());
|
||||
}
|
||||
|
||||
auto model{ std::make_shared<UserModel>(userId, authPasswordService, *core::Service<auth::IAuthTokenService>::get()) };
|
||||
if (userId)
|
||||
{
|
||||
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace lms::ui
|
||||
|
||||
_container = bindNew<Wt::WContainerWidget>("users");
|
||||
|
||||
if (core::Service<auth::IPasswordService>::get() && core::Service<auth::IPasswordService>::get()->canSetPasswords())
|
||||
if (LmsApp->getAuthBackend() == AuthenticationBackend::Internal)
|
||||
{
|
||||
setCondition("if-can-create-user", true);
|
||||
|
||||
|
||||
@@ -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<auth::IPasswordService>::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<Wt::WValidator> createPasswordStrengthValidator(PasswordValidationContextGetFunc passwordValidationContextGetFunc)
|
||||
std::unique_ptr<Wt::WValidator> createPasswordStrengthValidator(const auth::IPasswordService& passwordService, PasswordValidationContextGetFunc passwordValidationContextGetFunc)
|
||||
{
|
||||
return std::make_unique<PasswordStrengthValidator>(std::move(passwordValidationContextGetFunc));
|
||||
return std::make_unique<PasswordStrengthValidator>(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<auth::IPasswordService>::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<Wt::WValidator> createPasswordCheckValidator()
|
||||
std::unique_ptr<Wt::WValidator> createPasswordCheckValidator(auth::IPasswordService& passwordService)
|
||||
{
|
||||
return std::make_unique<PasswordCheckValidator>();
|
||||
return std::make_unique<PasswordCheckValidator>(passwordService);
|
||||
}
|
||||
|
||||
} // namespace lms::ui
|
||||
@@ -25,11 +25,16 @@
|
||||
|
||||
#include "services/auth/Types.hpp"
|
||||
|
||||
namespace lms::auth
|
||||
{
|
||||
class IPasswordService;
|
||||
}
|
||||
|
||||
namespace lms::ui
|
||||
{
|
||||
using PasswordValidationContextGetFunc = std::function<auth::PasswordValidationContext()>;
|
||||
std::unique_ptr<Wt::WValidator> createPasswordStrengthValidator(PasswordValidationContextGetFunc passwordValidationContextGetFunc);
|
||||
std::unique_ptr<Wt::WValidator> createPasswordStrengthValidator(const auth::IPasswordService& passwordService, PasswordValidationContextGetFunc passwordValidationContextGetFunc);
|
||||
|
||||
// Check current user password
|
||||
std::unique_ptr<Wt::WValidator> createPasswordCheckValidator();
|
||||
std::unique_ptr<Wt::WValidator> createPasswordCheckValidator(auth::IPasswordService& passwordService);
|
||||
} // namespace lms::ui
|
||||
|
||||
Reference in New Issue
Block a user