From b2f0c99f79aa738a20cc2f64c2b3b9485592cfcc Mon Sep 17 00:00:00 2001 From: vithyze <127023076+vithyze@users.noreply.github.com> Date: Sun, 5 Mar 2023 12:07:52 +0000 Subject: [PATCH] Apply changes --- config.sample | 21 +++++ supysonic/api/__init__.py | 15 +++- supysonic/cli.py | 125 +++++++++++++++++++++++---- supysonic/config.py | 9 ++ supysonic/db.py | 7 +- supysonic/frontend/user.py | 54 ++++++++++-- supysonic/ldap.py | 47 ++++++++++ supysonic/managers/user.py | 32 ++++++- supysonic/schema/mysql.sql | 6 +- supysonic/schema/postgres.sql | 6 +- supysonic/schema/sqlite.sql | 6 +- supysonic/templates/change_mail.html | 2 +- supysonic/templates/profile.html | 27 +++++- supysonic/templates/users.html | 4 +- 14 files changed, 322 insertions(+), 39 deletions(-) create mode 100644 supysonic/ldap.py diff --git a/config.sample b/config.sample index 897cdd77..a870ac9d 100644 --- a/config.sample +++ b/config.sample @@ -33,6 +33,9 @@ log_level = WARNING ; for testing purposes. Default: on ;mount_api = on +; Require API key for authentication. Default: yes +require_api_key = yes + ; Enable the administrative web interface. Default: on ;mount_webui = on @@ -87,3 +90,21 @@ default_transcode_target = mp3 ;mp3 = audio/mpeg ;ogg = audio/vorbis +[ldap] +; Server URL. Default: none +;url = ldapi://%2Frun%2Fslapd%2Fldapi +;url = ldap://127.0.0.1:389 + +; Bind credentials. Default: none +;bind_dn = cn=username,dc=example,dc=org +;bind_pw = password + +; Base DN. Default: none +;base_dn = ou=Users,dc=example,dc=org + +; User filter. The variable '{username}' is used for filtering. Default: none +;user_filter = (&(objectClass=inetOrgperson)(uid={username})) + +; Mail attribute. Default: mail +;mail_attr = mail + diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index 4965d4c9..7b9e3002 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -11,6 +11,7 @@ import uuid from flask import request from flask import Blueprint +from flask import current_app from peewee import IntegrityError from ..db import ClientPrefs, Folder @@ -56,10 +57,16 @@ def decode_password(password): @api.before_request def authorize(): + require_api_key = current_app.config["WEBAPP"]["require_api_key"] + if request.authorization: - user = UserManager.try_auth( + user = UserManager.try_auth_api( request.authorization.username, request.authorization.password ) + if user is None and not require_api_key: + user = UserManager.try_auth( + request.authorization.username, request.authorization.password + ) if user is not None: request.user = user return @@ -69,7 +76,11 @@ def authorize(): password = request.values["p"] password = decode_password(password) - user = UserManager.try_auth(username, password) + user = UserManager.try_auth_api(username, password) + if user is None and not require_api_key: + user = UserManager.try_auth( + request.authorization.username, request.authorization.password + ) if user is None: raise Unauthorized() diff --git a/supysonic/cli.py b/supysonic/cli.py index a586057d..3d095c8b 100644 --- a/supysonic/cli.py +++ b/supysonic/cli.py @@ -7,6 +7,7 @@ import click import time +import uuid from click.exceptions import ClickException @@ -236,12 +237,12 @@ def user(): def user_list(): """Lists users.""" - click.echo("Name\t\tAdmin\tJukebox\tEmail") - click.echo("----\t\t-----\t-------\t-----") + click.echo("Name\t\tLDAP\tAdmin\tJukebox\tEmail") + click.echo("----\t\t-----\t-----\t-------\t-----") for u in User.select(): click.echo( - "{: <16}{}\t{}\t{}".format( - u.name, "*" if u.admin else "", "*" if u.jukebox else "", u.mail + "{: <16}{}\t{}\t{}\t{}".format( + u.name, "*" if u.ldap else "", "*" if u.admin else "", "*" if u.jukebox else "", u.mail ) ) @@ -249,7 +250,7 @@ def user_list(): @user.command("add") @click.argument("name") @click.password_option("-p", "--password", help="Specifies the user's password") -@click.option("-e", "--email", default="", help="Sets the user's email address") +@click.option("-e", "--email", default=None, help="Sets the user's email address") def user_add(name, password, email): """Adds a new user. @@ -262,10 +263,42 @@ def user_add(name, password, email): raise ClickException(str(e)) from e +@user.group("edit") +def user_edit(): + """User edit commands""" + pass + + +@user_edit.command("email") +@click.argument("name") +@click.option("-e", "--email", prompt=True, default="", help="Sets the user's email address") +def user_edit_email(name, email): + """Changes an user's email. + + + NAME is the name (or login) of the user to edit. + """ + + user = User.get(name=name) + if user is None: + raise ClickException("No such user") + + if user.ldap: + raise ClickException("Unavailable for LDAP users") + + if email == "": + email = None + + if user.mail != email: + user.mail = email + user.save() + click.echo(f"Updated user '{name}'") + + @user.command("delete") @click.argument("name") def user_delete(name): - """Deletes a user. + """Deletes an user. NAME is the name of the user to delete. """ @@ -295,7 +328,7 @@ def _echo_role_change(username, name, value): help="Grant or revoke jukebox rights", ) def user_roles(name, admin, jukebox): - """Enable/disable rights for a user. + """Enable/disable rights for an user. NAME is the login of the user to which grant or revoke rights. """ @@ -314,27 +347,31 @@ def user_roles(name, admin, jukebox): user.save() -@user.command("changepass") +@user_edit.command("password") @click.argument("name") @click.password_option("-p", "--password", help="New password") def user_changepass(name, password): - """Changes a user's password. + """Changes an user's password. NAME is the login of the user to which change the password. """ - try: - UserManager.change_password2(name, password) - click.echo(f"Successfully changed '{name}' password") - except User.DoesNotExist as e: - raise ClickException(f"User '{name}' does not exist.") from e + user = User.get(name=name) + if user is None: + raise ClickException(f"User '{name}' does not exist.") + + if user.ldap: + raise ClickException("Unavailable for LDAP users") + UserManager.change_password2(name, password) + click.echo(f"Successfully changed '{name}' password") -@user.command("rename") + +@user_edit.command("username") @click.argument("name") @click.argument("newname") def user_rename(name, newname): - """Renames a user. + """Renames an user. User NAME will then be known as NEWNAME. """ @@ -361,6 +398,62 @@ def user_rename(name, newname): click.echo(f"User '{name}' renamed to '{newname}'") +@user.group("apikey") +def user_apikey(): + """User API key commands""" + pass + + +@user_apikey.command("show") +@click.argument("name") +def user_apikey_show(name): + """Shows the API key of an user. + + NAME is the name (or login) of the user to show the API key. + """ + + user = User.get(name=name) + if user is None: + raise ClickException(f"User '{name}' does not exist.") + + click.echo(f"'{name}' API key: {user.api_key}") + + +@user_apikey.command("new") +@click.argument("name") +def user_apikey_new(name): + """Generates a new API key for an user. + + NAME is the name (or login) of the user to generate the API key for. + """ + + user = User.get(name=name) + if user is None: + raise ClickException(f"User '{name}' does not exist.") + + user.api_key = str(uuid.uuid4()).replace("-", "") + user.save() + click.echo(f"Updated '{name}' API key") + + +@user_apikey.command("delete") +@click.argument("name") +def user_apikey_delete(name): + """Deletes the API key of an user. + + NAME is the name (or login) of the user to delete the API key. + """ + + user = User.get(name=name) + if user is None: + raise ClickException(f"User '{name}' does not exist.") + + if user.api_key is not None: + user.api_key = None + user.save() + click.echo(f"Deleted '{name}' API key") + + def main(): config = IniConfig.from_common_locations() init_database(config.BASE["database_uri"]) diff --git a/supysonic/config.py b/supysonic/config.py index 3d913b24..fadb9c5c 100644 --- a/supysonic/config.py +++ b/supysonic/config.py @@ -36,6 +36,7 @@ class DefaultConfig: "log_level": "WARNING", "mount_webui": True, "mount_api": True, + "require_api_key": True, "index_ignored_prefixes": "El La Le Las Les Los The", } DAEMON = { @@ -51,6 +52,14 @@ class DefaultConfig: LASTFM = {"api_key": None, "secret": None} TRANSCODING = {} MIMETYPES = {} + LDAP = { + "url": None, + "bind_dn": None, + "bind_pw": None, + "base_dn": None, + "user_filter": None, + "mail_attr": "mail" + } def __init__(self): current_config = self diff --git a/supysonic/db.py b/supysonic/db.py index 0c745cdc..41733f2b 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -428,12 +428,15 @@ class User(_Model): id = PrimaryKeyField() name = CharField(64, unique=True) mail = CharField(null=True) - password = FixedCharField(40) - salt = FixedCharField(6) + password = FixedCharField(40, null=True) + salt = FixedCharField(6, null=True) + ldap = BooleanField(default=False) admin = BooleanField(default=False) jukebox = BooleanField(default=False) + api_key = CharField(32, null=True) + lastfm_session = FixedCharField(32, null=True) lastfm_status = BooleanField( default=True diff --git a/supysonic/frontend/user.py b/supysonic/frontend/user.py index c69fe58d..7fd87c4f 100644 --- a/supysonic/frontend/user.py +++ b/supysonic/frontend/user.py @@ -6,6 +6,7 @@ # Distributed under terms of the GNU AGPLv3 license. import logging +import uuid from flask import flash, redirect, render_template, request, session, url_for from flask import current_app @@ -172,22 +173,37 @@ def change_username_post(uid): @frontend.route("/user//changemail") @me_or_uuid def change_mail_form(uid, user): - return render_template("change_mail.html", user=user) + if user.ldap: + flash("Unavailable for LDAP users") + return redirect(url_for("frontend.user_profile", uid=uid)) + else: + return render_template("change_mail.html", user=user) @frontend.route("/user//changemail", methods=["POST"]) @me_or_uuid def change_mail_post(uid, user): mail = request.form.get("mail", "") - # No validation, lol. - user.mail = mail + if mail == "": + mail = None + if user.mail == mail: + flash("No changes made") + else: + # No validation, lol. + user.mail = mail + user.save() + flash("Email changed") return redirect(url_for("frontend.user_profile", uid=uid)) @frontend.route("/user//changepass") @me_or_uuid def change_password_form(uid, user): - return render_template("change_pass.html", user=user) + if user.ldap: + flash("Unavailable for LDAP users") + return redirect(url_for("frontend.user_profile", uid=uid)) + else: + return render_template("change_pass.html", user=user) @frontend.route("/user//changepass", methods=["POST"]) @@ -235,8 +251,8 @@ def add_user_form(): def add_user_post(): error = False args = request.form.copy() - (name, passwd, passwd_confirm) = map( - args.pop, ("user", "passwd", "passwd_confirm"), (None,) * 3 + (name, passwd, passwd_confirm, mail) = map( + args.pop, ("user", "passwd", "passwd_confirm", "mail"), (None,) * 4 ) if not name: flash("The name is required.") @@ -248,9 +264,12 @@ def add_user_post(): flash("The passwords don't match.") error = True + if mail == "": + mail = None + if not error: try: - UserManager.add(name, passwd, **args) + UserManager.add(name, passwd, mail=mail, **args) flash(f"User '{name}' successfully added") return redirect(url_for("frontend.user_index")) except ValueError as e: @@ -333,3 +352,24 @@ def logout(): session.clear() flash("Logged out!") return redirect(url_for("frontend.login")) + + +@frontend.route("/user//new_api_key") +@me_or_uuid +def new_api_key(uid, user): + user.api_key = str(uuid.uuid4()).replace("-", "") + user.save() + flash("API key updated") + return redirect(url_for("frontend.user_profile", uid=uid)) + + +@frontend.route("/user//del_api_key") +@me_or_uuid +def del_api_key(uid, user): + if user.api_key is None: + flash("No changes made") + else: + user.api_key = None + user.save() + flash("API key deleted") + return redirect(url_for("frontend.user_profile", uid=uid)) diff --git a/supysonic/ldap.py b/supysonic/ldap.py new file mode 100644 index 00000000..8ab3cb93 --- /dev/null +++ b/supysonic/ldap.py @@ -0,0 +1,47 @@ +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2013-2018 Alban 'spl0k' FĂ©ron +# +# Distributed under terms of the GNU AGPLv3 license. + +import logging + +try: + import ldap3 +except ModuleNotFoundError: + ldap3 = None + +from flask import current_app + +logger = logging.getLogger(__name__) + + +class Ldap: + @staticmethod + def try_auth(username, password): + config = current_app.config["LDAP"] + if None in config.values(): + return + elif not ldap3: + logger.warning("Module 'ldap3' is not installed.") + return + + server = ldap3.Server(config["url"], get_info=None) + + with ldap3.Connection(server, config["bind_dn"], config["bind_pw"], read_only=True) as conn: + conn.search( + config["base_dn"], + config["user_filter"].format(username=username), + search_scope=ldap3.LEVEL, + attributes=[config["mail_attr"]], + size_limit=1 + ) + entries = conn.entries + + if entries: + try: + with ldap3.Connection(server, entries[0].entry_dn, password, read_only=True): + return {"mail": entries[0][config["mail_attr"]].value} + except ldap3.core.exceptions.LDAPBindError: + return False diff --git a/supysonic/managers/user.py b/supysonic/managers/user.py index 23215c0b..705e5745 100644 --- a/supysonic/managers/user.py +++ b/supysonic/managers/user.py @@ -12,6 +12,7 @@ import uuid from ..db import User +from ..ldap import Ldap class UserManager: @@ -45,15 +46,42 @@ def delete_by_name(name): user.delete_instance(recursive=True) @staticmethod - def try_auth(name, password): + def try_auth_api(name, password): user = User.get_or_none(name=name) if user is None: return None - elif UserManager.__encrypt_password(password, user.salt)[0] != user.password: + if user.api_key is None: + return None + elif password != user.api_key: return None else: return user + @staticmethod + def try_auth(name, password): + ldap_user = Ldap.try_auth(name, password) + user = User.get_or_none(name=name) + + if ldap_user is None: + if user is None: + return None + elif UserManager.__encrypt_password(password, user.salt)[0] != user.password: + return None + else: + return user + elif ldap_user: + if user is None: + user = User.create(name=name, mail=ldap_user["mail"], ldap=True) + return user + elif not user.ldap: + return None + else: + if user.mail != ldap_user["mail"]: + user.mail = ldap_user["mail"] + return user + else: + return None + @staticmethod def change_password(uid, old_pass, new_pass): user = UserManager.get(uid) diff --git a/supysonic/schema/mysql.sql b/supysonic/schema/mysql.sql index 4c85bbac..8f269d96 100644 --- a/supysonic/schema/mysql.sql +++ b/supysonic/schema/mysql.sql @@ -53,10 +53,12 @@ CREATE TABLE IF NOT EXISTS user ( id CHAR(32) PRIMARY KEY, name VARCHAR(64) NOT NULL, mail VARCHAR(256), - password CHAR(40) NOT NULL, - salt CHAR(6) NOT NULL, + password CHAR(40), + salt CHAR(6), + ldap BOOLEAN NOT NULL, admin BOOLEAN NOT NULL, jukebox BOOLEAN NOT NULL, + api_key CHAR(32), lastfm_session CHAR(32), lastfm_status BOOLEAN NOT NULL, last_play_id CHAR(32) REFERENCES track(id), diff --git a/supysonic/schema/postgres.sql b/supysonic/schema/postgres.sql index 1984266e..15f7a1d1 100644 --- a/supysonic/schema/postgres.sql +++ b/supysonic/schema/postgres.sql @@ -53,10 +53,12 @@ CREATE TABLE IF NOT EXISTS "user" ( id UUID PRIMARY KEY, name VARCHAR(64) NOT NULL, mail VARCHAR(256), - password CHAR(40) NOT NULL, - salt CHAR(6) NOT NULL, + password CHAR(40), + salt CHAR(6), + ldap BOOLEAN NOT NULL, admin BOOLEAN NOT NULL, jukebox BOOLEAN NOT NULL, + api_key CHAR(32), lastfm_session CHAR(32), lastfm_status BOOLEAN NOT NULL, last_play_id UUID REFERENCES track, diff --git a/supysonic/schema/sqlite.sql b/supysonic/schema/sqlite.sql index ae39a87b..ac290e98 100644 --- a/supysonic/schema/sqlite.sql +++ b/supysonic/schema/sqlite.sql @@ -55,10 +55,12 @@ CREATE TABLE IF NOT EXISTS user ( id CHAR(36) PRIMARY KEY, name VARCHAR(64) NOT NULL, mail VARCHAR(256), - password CHAR(40) NOT NULL, - salt CHAR(6) NOT NULL, + password CHAR(40), + salt CHAR(6), + ldap BOOLEAN NOT NULL, admin BOOLEAN NOT NULL, jukebox BOOLEAN NOT NULL, + api_key CHAR(32), lastfm_session CHAR(32), lastfm_status BOOLEAN NOT NULL, last_play_id CHAR(36) REFERENCES track, diff --git a/supysonic/templates/change_mail.html b/supysonic/templates/change_mail.html index 4f6b0176..bbf0490c 100644 --- a/supysonic/templates/change_mail.html +++ b/supysonic/templates/change_mail.html @@ -32,7 +32,7 @@

{{ user.name }}

- +
diff --git a/supysonic/templates/profile.html b/supysonic/templates/profile.html index 56c29afa..6ef81f07 100644 --- a/supysonic/templates/profile.html +++ b/supysonic/templates/profile.html @@ -25,7 +25,9 @@ {% endblock %} {% block body %} @@ -37,6 +39,7 @@

{{ user.name }}{% if user.admin %}
User eMail
+ {% if not user.ldap %}
{% if request.user.id == user.id %} Change eMail @@ -44,6 +47,24 @@

{{ user.name }}{% if user.admin %} Change eMail {% endif %}

+ {% endif %} + + + + +
+
+
+ +
+
API key
+ +
+ + + + +
@@ -83,11 +104,15 @@

{{ user.name }}{% if user.admin %} Change password +{% endif %} {% else %} Change username or admin status +{% if not user.ldap %} Change password {% endif %} +{% endif %} {% if clients.count() %} - + {% for user in users %} - {% endfor %}
NameEMailAdminLast play date
NameEMailLDAPAdminLast play date
{% if request.user.id == user.id %}{{ user.name }}{% else %} {{ user.name }}{% endif %}{{ user.mail }}{{ user.admin }}{{ user.last_play_date }} + {{ user.mail }}{{ user.ldap }}{{ user.admin }}{{ user.last_play_date }} {% if request.user.id != user.id %}{% endif %}