1
0
mirror of https://github.com/spl0k/supysonic.git synced 2024-09-19 19:01:03 +00:00

Apply changes

This commit is contained in:
vithyze 2023-03-05 12:07:52 +00:00
parent 8e2adf8fc8
commit b2f0c99f79
14 changed files with 322 additions and 39 deletions

View File

@ -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

View File

@ -11,6 +11,7 @@ import binascii
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()

View File

@ -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"])

View File

@ -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

View File

@ -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

View File

@ -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/<uid>/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/<uid>/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/<uid>/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/<uid>/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/<uid>/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/<uid>/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))

47
supysonic/ldap.py Normal file
View File

@ -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

View File

@ -12,6 +12,7 @@ import string
import uuid
from ..db import User
from ..ldap import Ldap
class UserManager:
@ -45,15 +46,42 @@ class UserManager:
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)

View File

@ -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),

View File

@ -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,

View File

@ -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,

View File

@ -32,7 +32,7 @@
<label class="sr-only" for="mail">eMail</label>
<div class="input-group">
<div class="input-group-addon"><span class="glyphicon glyphicon-envelope"></span></div>
<input type="text" class="form-control" id="mail" name="mail" value="{{ request.form.mail or user.mail }}" placeholder="eMail" />
<input type="text" class="form-control" id="mail" name="mail" value="{{ user.mail or "" }}" placeholder="eMail" />
</div>
</div>
<input type="submit" class="btn btn-default" />

View File

@ -25,7 +25,9 @@
{% endblock %}
{% block body %}
<div class="page-header first-header">
<h2>{{ user.name }}{% if user.admin %} <small><span class="glyphicon
<h2>{{ user.name }}{% if user.ldap %} <small><span class="glyphicon
glyphicon-cloud" data-toggle="tooltip" data-placement="right" title="LDAP"></span></small>{% endif %}
{% if user.admin %} <small><span class="glyphicon
glyphicon-certificate" data-toggle="tooltip" data-placement="right"
title="{% if request.user.id == user.id %}You're an admin!{% else %}The user is an admin!{% endif %}"></span></small>{% endif %}</h2>
</div>
@ -37,6 +39,7 @@
<div class="input-group">
<div class="input-group-addon">User eMail</div>
<input type="text" class="form-control" id="email" placeholder="{{ user.mail }}" readonly>
{% if not user.ldap %}
<div class="input-group-btn">
{% if request.user.id == user.id %}
<a href="{{ url_for('frontend.change_mail_form', uid = 'me') }}" class="btn btn-default">Change eMail</a>
@ -44,6 +47,24 @@
<a href="{{ url_for('frontend.change_mail_form', uid = user.id) }}" class="btn btn-default">Change eMail</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
</form>
</div>
<div class="col-md-6">
<form>
<div class="form-group">
<label class="sr-only" for="apikey">API key</label>
<div class="input-group">
<div class="input-group-addon">API key</div>
<input type="text" class="form-control" id="apikey" {% if user.api_key %}value{% else %}placeholder{% endif %}="{{ user.api_key }}" readonly>
<div class="input-group-btn">
<a href="{{ url_for('frontend.new_api_key', uid = 'me') if request.user.id == user.id else url_for('frontend.new_api_key', uid = user.id) }}" class="btn btn-default">
<span class="glyphicon glyphicon-refresh" aria-hidden="true" data-toggle="tooltip" data-placement="top" title="Generate key"></span></a>
<a href="{{ url_for('frontend.del_api_key', uid = 'me') if request.user.id == user.id else url_for('frontend.del_api_key', uid = user.id) }}" class="btn btn-default{% if not user.api_key %} disabled{% endif %}">
<span class="glyphicon glyphicon-remove-circle" aria-hidden="true" data-toggle="tooltip" data-placement="top" title="Delete key"></span></a>
</div>
</div>
</div>
</form>
@ -83,11 +104,15 @@
</div>
</div>
{% if request.user.id == user.id %}
{% if not user.ldap %}
<a href="{{ url_for('frontend.change_password_form', uid = 'me') }}" class="btn btn-default">Change password</a></li>
{% endif %}
{% else %}
<a href="{{ url_for('frontend.change_username_form', uid = user.id) }}" class="btn btn-default">Change username or admin status</a></li>
{% if not user.ldap %}
<a href="{{ url_for('frontend.change_password_form', uid = user.id) }}" class="btn btn-default">Change password</a></li>
{% endif %}
{% endif %}
{% if clients.count() %}
<div class="page-header">
<h2>Clients</h2>

View File

@ -18,14 +18,14 @@
</div>
<table class="table table-striped table-hover">
<thead>
<tr><th>Name</th><th>EMail</th><th>Admin</th><th>Last play date</th><th></th></tr>
<tr><th>Name</th><th>EMail</th><th>LDAP</th><th>Admin</th><th>Last play date</th><th></th></tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{% if request.user.id == user.id %}{{ user.name }}{% else %}
<a href="{{ url_for('frontend.user_profile', uid = user.id) }}">{{ user.name }}</a>{% endif %}</td>
<td>{{ user.mail }}</td><td>{{ user.admin }}</td><td>{{ user.last_play_date }}</td><td>
<td>{{ user.mail }}</td><td>{{ user.ldap }}</td><td>{{ user.admin }}</td><td>{{ user.last_play_date }}</td><td>
{% if request.user.id != user.id %}<button class="btn btn-danger btn-xs" data-href="{{ url_for('frontend.del_user', uid = user.id) }}" data-toggle="modal" data-target="#confirm-delete" aria-label="Delete user">
<span class="glyphicon glyphicon-remove-circle" aria-hidden="true" data-toggle="tooltip" data-placement="top" title="Delete user"></span></button>{% endif %}</td></tr>
{% endfor %}