Compare commits

...

25 Commits

Author SHA1 Message Date
Tim Gröger e4b937991b [pricelist] add serverside pagination and filter for receipts 2021-11-13 15:44:06 +01:00
Tim Gröger 526433afba [pricelist] serviceside filtering for ingredients 2021-11-13 15:03:21 +01:00
Tim Gröger 8fb74358e7 [pricelist] add serverside filtering for getDrinks 2021-11-13 13:23:04 +01:00
Tim Gröger 26a00ed6a6 [pricelist] add serverside pagination of drinks 2021-11-12 22:09:16 +01:00
Tim Gröger ff13eefb45 Merge branch 'develop' into feature/pricelist 2021-11-11 19:49:43 +01:00
Tim Gröger 5ef603ee50 Merge pull request '[auth_ldap] Allow more configuration' (#14) from improve_ldap into develop
Reviewed-on: #14
2021-11-11 18:43:17 +00:00
Ferdinand Thiessen 80f06e483b [auth_ldap] modify_role has to be called before the update to change it on the backend 2021-11-11 15:23:11 +01:00
Ferdinand Thiessen f80ad5c420 [auth_ldap] Fix typo in __init__ 2021-11-11 15:23:11 +01:00
Ferdinand Thiessen 4e1799e297 [auth_ldap] Allow more configuration
* Allow configuring the password hash (SSHA, PBKDF2 or Argon2)
* Allow setting custom dn templates for users and groups to e.g. allow "ou=people" or "ou=user"
* Allow setting custom object class for entries
* Stop using deprecated openssl constants
2021-11-11 15:23:11 +01:00
Ferdinand Thiessen f7e07fdade [events] Hotfix: delete an event with registered jobs 2021-11-11 15:22:55 +01:00
Ferdinand Thiessen 3d833fb6af [plugin] Plugins should have an unique ID 2021-11-11 15:22:15 +01:00
Ferdinand Thiessen 2dabd1dd34 [events] Fix deleteing an event 2021-11-11 12:23:45 +01:00
Ferdinand Thiessen cadde543f2 [plugins] Improved handling of plugin loading errors 2021-08-30 14:39:54 +02:00
Tim Gröger 4e46ea1ca3 [balance] add get and modify limits for all users 2021-08-09 10:06:51 +00:00
Tim Gröger 0d1a39f217 [balance] add sorting of transaction 2021-08-09 10:06:51 +00:00
Ferdinand Thiessen 7129469835 [roles] MySQL is caseinsensitive for strings so workaround it for renaming roles 2021-07-29 17:18:01 +02:00
Tim Gröger e7b978ae3c better drink dependency
drink_ingredient has name and cost_per_volume
Flaschengeist/flaschengeist-pricelist#2
2021-06-30 10:45:41 +02:00
Tim Gröger ff1a0544f8 Merge branch 'develop' into feature/pricelist 2021-06-29 20:21:50 +02:00
Ferdinand Thiessen 3fc04c4143 [docs] Moved some devel docs to the wiki 2021-05-27 01:52:45 +02:00
Ferdinand Thiessen 7928c16c07 [db] Try mysqlclient first, maybe the user managed to get it working on Windows 2021-05-27 01:52:30 +02:00
Ferdinand Thiessen 7b5f854d51 [db] Support sqlite and postgresql as engine, fixes #5
mysql / mariadb still is the only tested configuration.
This will break existing databases, as UTF8MB4 is enforced for
mysql (real UTF8).
2021-05-27 01:27:53 +02:00
Ferdinand Thiessen 8696699ecb [run_flaschengeist] Improved export command
Export now supports --no-core flag, if set no core models will
get exported.
Also --plugins was changed to support a list of plugins,
if no list is given the old behavior is taken (export all plugins).
If a list of plugins is given, only those plugins are exported.
2021-05-26 16:47:03 +02:00
groegert 776332d5fe added some hints to ease the installation 2021-05-20 15:37:17 +00:00
Tim Gröger 96e4e73f4b [users] add dynamic shortcuts for users 2021-04-18 23:28:05 +02:00
Tim Gröger 1e5304fe1e Merge branch 'feature/pricelist' into develop 2021-04-17 18:32:49 +02:00
21 changed files with 386 additions and 239 deletions

View File

@ -54,6 +54,8 @@ def __load_plugins(app):
logger.error( logger.error(
f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", exc_info=True f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", exc_info=True
) )
del plugin
continue
if isinstance(plugin, AuthPlugin): if isinstance(plugin, AuthPlugin):
logger.debug(f"Found authentication plugin: {entry_point.name}") logger.debug(f"Found authentication plugin: {entry_point.name}")
if entry_point.name == config["FLASCHENGEIST"]["auth"]: if entry_point.name == config["FLASCHENGEIST"]["auth"]:
@ -65,6 +67,7 @@ def __load_plugins(app):
app.config["FG_PLUGINS"][entry_point.name] = plugin app.config["FG_PLUGINS"][entry_point.name] = plugin
if "FG_AUTH_BACKEND" not in app.config: if "FG_AUTH_BACKEND" not in app.config:
logger.error("No authentication plugin configured or authentication plugin not found") logger.error("No authentication plugin configured or authentication plugin not found")
raise RuntimeError("No authentication plugin configured or authentication plugin not found")
def install_all(): def install_all():

View File

@ -9,7 +9,7 @@ from flaschengeist import _module_path, logger
# Default config: # Default config:
config = {"DATABASE": {"port": 3306}} config = {"DATABASE": {"engine": "mysql", "port": 3306}}
def update_dict(d, u): def update_dict(d, u):
@ -65,17 +65,35 @@ def configure_app(app, test_config=None):
else: else:
app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"] app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"]
if test_config is None: if test_config is not None:
app.config["SQLALCHEMY_DATABASE_URI"] = "mysql{driver}://{user}:{passwd}@{host}:{port}/{database}".format( config["DATABASE"]["engine"] = "sqlite"
driver="+pymysql" if os.name == "nt" else "",
if config["DATABASE"]["engine"] == "mysql":
engine = "mysql"
try:
# Try mysqlclient first
from MySQLdb import _mysql
except ModuleNotFoundError:
engine += "+pymysql"
options = "?charset=utf8mb4"
elif config["DATABASE"]["engine"] == "postgres":
engine = "postgresql+psycopg2"
options = "?client_encoding=utf8"
elif config["DATABASE"]["engine"] == "sqlite":
engine = "sqlite"
options = ""
host = ""
else:
logger.error(f"Invalid database engine configured. >{config['DATABASE']['engine']}< is unknown")
raise Exception
if config["DATABASE"]["engine"] in ["mysql", "postgresql"]:
host = "{user}:{password}@{host}:{port}".format(
user=config["DATABASE"]["user"], user=config["DATABASE"]["user"],
passwd=config["DATABASE"]["password"], password=config["DATABASE"]["password"],
host=config["DATABASE"]["host"], host=config["DATABASE"]["host"],
database=config["DATABASE"]["database"],
port=config["DATABASE"]["port"], port=config["DATABASE"]["port"],
) )
else: app.config["SQLALCHEMY_DATABASE_URI"] = f"{engine}://{host}/{config['DATABASE']['database']}{options}"
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite+pysqlite://{config['DATABASE']['file_path']}"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
if "root" in config["FLASCHENGEIST"]: if "root" in config["FLASCHENGEIST"]:

View File

@ -2,7 +2,7 @@ from sqlalchemy.exc import IntegrityError
from werkzeug.exceptions import BadRequest, NotFound from werkzeug.exceptions import BadRequest, NotFound
from flaschengeist.models.user import Role, Permission from flaschengeist.models.user import Role, Permission
from flaschengeist.database import db from flaschengeist.database import db, case_sensitive
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.utils.hook import Hook from flaschengeist.utils.hook import Hook
@ -36,8 +36,8 @@ def update_role(role, new_name):
except IntegrityError: except IntegrityError:
logger.debug("IntegrityError: Role might still be in use", exc_info=True) logger.debug("IntegrityError: Role might still be in use", exc_info=True)
raise BadRequest("Role still in use") raise BadRequest("Role still in use")
elif role.name != new_name: else:
if db.session.query(db.exists().where(Role.name == new_name)).scalar(): if role.name == new_name or db.session.query(db.exists().where(Role.name == case_sensitive(new_name))).scalar():
raise BadRequest("Name already used") raise BadRequest("Name already used")
role.name = new_name role.name = new_name
db.session.commit() db.session.commit()

View File

@ -1,3 +1,10 @@
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy() db = SQLAlchemy()
def case_sensitive(s):
if db.session.bind.dialect.name == "mysql":
from sqlalchemy import func
return func.binary(s)
return s

View File

@ -20,6 +20,7 @@ secret_key = "V3ryS3cr3t"
level = "WARNING" level = "WARNING"
[DATABASE] [DATABASE]
# engine = "mysql" (default)
# user = "user" # user = "user"
# host = "127.0.0.1" # host = "127.0.0.1"
# password = "password" # password = "password"
@ -28,17 +29,16 @@ level = "WARNING"
[auth_plain] [auth_plain]
enabled = true enabled = true
#[auth_ldap] [auth_ldap]
# enabled = true # Full documentation https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/plugins_auth_ldap
# host = enabled = false
# port = # host = "localhost"
# bind_dn = # port = 389
# base_dn = # base_dn = "dc=example,dc=com"
# secret = # root_dn = "cn=Manager,dc=example,dc=com"
# use_ssl = # root_secret = "SuperS3cret"
# admin_dn = # Uncomment to use secured LDAP (ldaps)
# admin_dn = # use_ssl = true
# default_gid =
[MESSAGES] [MESSAGES]
welcome_subject = "Welcome to Flaschengeist {name}" welcome_subject = "Welcome to Flaschengeist {name}"

View File

@ -2,7 +2,7 @@ import sys
import datetime import datetime
from sqlalchemy import BigInteger from sqlalchemy import BigInteger
from sqlalchemy.dialects import mysql from sqlalchemy.dialects import mysql, sqlite
from sqlalchemy.types import DateTime, TypeDecorator from sqlalchemy.types import DateTime, TypeDecorator
@ -44,7 +44,7 @@ class ModelSerializeMixin:
class Serial(TypeDecorator): class Serial(TypeDecorator):
"""Same as MariaDB Serial used for IDs""" """Same as MariaDB Serial used for IDs"""
impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql") impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER, "sqlite")
class UtcDateTime(TypeDecorator): class UtcDateTime(TypeDecorator):

View File

@ -56,7 +56,7 @@ class User(db.Model, ModelSerializeMixin):
display_name: str = db.Column(db.String(30)) display_name: str = db.Column(db.String(30))
firstname: str = db.Column(db.String(50), nullable=False) firstname: str = db.Column(db.String(50), nullable=False)
lastname: str = db.Column(db.String(50), nullable=False) lastname: str = db.Column(db.String(50), nullable=False)
mail: str = db.Column(db.String(60), nullable=False) mail: str = db.Column(db.String(60))
birthday: Optional[date] = db.Column(db.Date) birthday: Optional[date] = db.Column(db.Date)
roles: list[str] = [] roles: list[str] = []
permissions: Optional[list[str]] = None permissions: Optional[list[str]] = None

View File

@ -33,6 +33,7 @@ class Plugin:
blueprint = None # You have to override blueprint = None # You have to override
permissions = [] # You have to override permissions = [] # You have to override
id = "dev.flaschengeist.plugin" # You have to override
name = "plugin" # You have to override name = "plugin" # You have to override
models = None # You have to override models = None # You have to override
@ -94,7 +95,7 @@ class Plugin:
db.session.commit() db.session.commit()
def notify(self, user, text: str, data=None): def notify(self, user, text: str, data=None):
n = Notification(text=text, data=data, plugin=self.name, user_=user) n = Notification(text=text, data=data, plugin=self.id, user_=user)
db.session.add(n) db.session.add(n)
db.session.commit() db.session.commit()

View File

@ -1,56 +1,61 @@
"""LDAP Authentication Provider Plugin""" """LDAP Authentication Provider Plugin"""
import io import io
import os
import ssl import ssl
from typing import Optional from typing import Optional
from flask_ldapconn import LDAPConn from flask_ldapconn import LDAPConn
from flask import current_app as app from flask import current_app as app
from ldap3.utils.hashed import hashed
from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError
from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SALTED_MD5 from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.plugins import AuthPlugin, after_role_updated from flaschengeist.plugins import AuthPlugin, before_role_updated
from flaschengeist.models.user import User, Role, _Avatar from flaschengeist.models.user import User, Role, _Avatar
import flaschengeist.controller.userController as userController import flaschengeist.controller.userController as userController
class AuthLDAP(AuthPlugin): class AuthLDAP(AuthPlugin):
def __init__(self, cfg): def __init__(self, config):
super().__init__() super().__init__()
config = {"port": 389, "use_ssl": False}
config.update(cfg)
app.config.update( app.config.update(
LDAP_SERVER=config["host"], LDAP_SERVER=config.get("host", "localhost"),
LDAP_PORT=config["port"], LDAP_PORT=config.get("port", 389),
LDAP_BINDDN=config["bind_dn"], LDAP_BINDDN=config.get("bind_dn", None),
LDAP_SECRET=config.get("secret", None),
LDAP_USE_SSL=config.get("use_ssl", False),
# That's not TLS, its dirty StartTLS on unencrypted LDAP
LDAP_USE_TLS=False, LDAP_USE_TLS=False,
LDAP_USE_SSL=config["use_ssl"], LDAP_TLS_VERSION=ssl.PROTOCOL_TLS,
LDAP_TLS_VERSION=ssl.PROTOCOL_TLSv1_2,
LDAP_REQUIRE_CERT=ssl.CERT_NONE,
FORCE_ATTRIBUTE_VALUE_AS_LIST=True, FORCE_ATTRIBUTE_VALUE_AS_LIST=True,
) )
if "secret" in config: logger.warning(app.config.get("LDAP_USE_SSL"))
app.config["LDAP_SECRET"] = config["secret"] if "ca_cert" in config:
app.config["LDAP_CA_CERTS_FILE"] = config["ca_cert"]
else:
# Default is CERT_REQUIRED
app.config["LDAP_REQUIRE_CERT"] = ssl.CERT_OPTIONAL
self.ldap = LDAPConn(app) self.ldap = LDAPConn(app)
self.dn = config["base_dn"] self.base_dn = config["base_dn"]
self.default_gid = config["default_gid"] self.search_dn = config.get("search_dn", "ou=people,{base_dn}").format(base_dn=self.base_dn)
self.group_dn = config.get("group_dn", "ou=group,{base_dn}").format(base_dn=self.base_dn)
self.password_hash = config.get("password_hash", "SSHA").upper()
self.object_classes = config.get("object_classes", ["inetOrgPerson"])
self.user_attributes: dict = config.get("user_attributes", {})
# TODO: might not be set if modify is called # TODO: might not be set if modify is called
if "admin_dn" in config: self.root_dn = config.get("root_dn", None)
self.admin_dn = config["admin_dn"] self.root_secret = config.get("root_secret", None)
self.admin_secret = config["admin_secret"]
else:
self.admin_dn = None
@after_role_updated @before_role_updated
def _role_updated(role, new_name): def _role_updated(role, new_name):
logger.debug(f"LDAP: before_role_updated called with ({role}, {new_name})")
self.__modify_role(role, new_name) self.__modify_role(role, new_name)
def login(self, user, password): def login(self, user, password):
if not user: if not user:
return False return False
return self.ldap.authenticate(user.userid, password, "uid", self.dn) return self.ldap.authenticate(user.userid, password, "uid", self.base_dn)
def find_user(self, userid, mail=None): def find_user(self, userid, mail=None):
attr = self.__find(userid, mail) attr = self.__find(userid, mail)
@ -64,42 +69,41 @@ class AuthLDAP(AuthPlugin):
self.__update(user, attr) self.__update(user, attr)
def create_user(self, user, password): def create_user(self, user, password):
if self.admin_dn is None: if self.root_dn is None:
logger.error("admin_dn missing in ldap config!") logger.error("root_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
try: try:
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
self.ldap.connection.search( attributes = self.user_attributes.copy()
"ou=user,{}".format(self.dn), if "uidNumber" in attributes:
"(uidNumber=*)", self.ldap.connection.search(
SUBTREE, self.search_dn,
attributes=["uidNumber"], "(uidNumber=*)",
SUBTREE,
attributes=["uidNumber"],
)
resp = sorted(
self.ldap.response(),
key=lambda i: i["attributes"]["uidNumber"],
reverse=True,
)
attributes = resp[0]["attributes"]["uidNumber"] + 1 if resp else attributes["uidNumber"]
dn = self.dn_template.format(
firstname=user.firstname,
lastname=user.lastname,
userid=user.userid,
mail=user.mail,
display_name=user.display_name,
base_dn=self.base_dn,
) )
uid_number = ( attributes.update({
sorted(self.ldap.response(), key=lambda i: i["attributes"]["uidNumber"], reverse=True,)[0][ "sn": user.lastname,
"attributes" "givenName": user.firstname,
]["uidNumber"]
+ 1
)
dn = f"cn={user.firstname} {user.lastname},ou=user,{self.dn}"
object_class = [
"inetOrgPerson",
"posixAccount",
"person",
"organizationalPerson",
]
attributes = {
"sn": user.firstname,
"givenName": user.lastname,
"gidNumber": self.default_gid,
"homeDirectory": f"/home/{user.userid}",
"loginShell": "/bin/bash",
"uid": user.userid, "uid": user.userid,
"userPassword": hashed(HASHED_SALTED_MD5, password), "userPassword": self.__hash(password),
"uidNumber": uid_number, })
} ldap_conn.add(dn, self.object_classes, attributes)
ldap_conn.add(dn, object_class, attributes)
self._set_roles(user) self._set_roles(user)
except (LDAPPasswordIsMandatoryError, LDAPBindError): except (LDAPPasswordIsMandatoryError, LDAPBindError):
raise BadRequest raise BadRequest
@ -110,10 +114,10 @@ class AuthLDAP(AuthPlugin):
if password: if password:
ldap_conn = self.ldap.connect(dn, password) ldap_conn = self.ldap.connect(dn, password)
else: else:
if self.admin_dn is None: if self.root_dn is None:
logger.error("admin_dn missing in ldap config!") logger.error("root_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
modifier = {} modifier = {}
for name, ldap_name in [ for name, ldap_name in [
("firstname", "givenName"), ("firstname", "givenName"),
@ -124,9 +128,7 @@ class AuthLDAP(AuthPlugin):
if hasattr(user, name): if hasattr(user, name):
modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])] modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])]
if new_password: if new_password:
# TODO: Use secure hash! modifier["userPassword"] = [(MODIFY_REPLACE, [self.__hash(new_password)])]
salted_password = hashed(HASHED_SALTED_MD5, new_password)
modifier["userPassword"] = [(MODIFY_REPLACE, [salted_password])]
ldap_conn.modify(dn, modifier) ldap_conn.modify(dn, modifier)
self._set_roles(user) self._set_roles(user)
except (LDAPPasswordIsMandatoryError, LDAPBindError): except (LDAPPasswordIsMandatoryError, LDAPBindError):
@ -134,7 +136,7 @@ class AuthLDAP(AuthPlugin):
def get_avatar(self, user): def get_avatar(self, user):
self.ldap.connection.search( self.ldap.connection.search(
"ou=user,{}".format(self.dn), self.search_dn,
"(uid={})".format(user.userid), "(uid={})".format(user.userid),
SUBTREE, SUBTREE,
attributes=["jpegPhoto"], attributes=["jpegPhoto"],
@ -150,8 +152,8 @@ class AuthLDAP(AuthPlugin):
raise NotFound raise NotFound
def set_avatar(self, user, avatar: _Avatar): def set_avatar(self, user, avatar: _Avatar):
if self.admin_dn is None: if self.root_dn is None:
logger.error("admin_dn missing in ldap config!") logger.error("root_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
if avatar.mimetype != "image/jpeg": if avatar.mimetype != "image/jpeg":
@ -172,16 +174,16 @@ class AuthLDAP(AuthPlugin):
raise BadRequest("Unsupported image format") raise BadRequest("Unsupported image format")
dn = user.get_attribute("DN") dn = user.get_attribute("DN")
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]}) ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]})
def __find(self, userid, mail=None): def __find(self, userid, mail=None):
"""Find attributes of an user by uid or mail in LDAP""" """Find attributes of an user by uid or mail in LDAP"""
con = self.ldap.connection con = self.ldap.connection
if not con: if not con:
con = self.ldap.connect(self.admin_dn, self.admin_secret) con = self.ldap.connect(self.root_dn, self.root_secret)
con.search( con.search(
f"ou=user,{self.dn}", self.search_dn,
f"(| (uid={userid})(mail={mail}))" if mail else f"(uid={userid})", f"(| (uid={userid})(mail={mail}))" if mail else f"(uid={userid})",
SUBTREE, SUBTREE,
attributes=["uid", "givenName", "sn", "mail"], attributes=["uid", "givenName", "sn", "mail"],
@ -205,12 +207,12 @@ class AuthLDAP(AuthPlugin):
role: Role, role: Role,
new_name: Optional[str], new_name: Optional[str],
): ):
if self.admin_dn is None: if self.root_dn is None:
logger.error("admin_dn missing in ldap config!") logger.error("root_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
try: try:
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
ldap_conn.search(f"ou=group,{self.dn}", f"(cn={role.name})", SUBTREE, attributes=["cn"]) ldap_conn.search(self.group_dn, f"(cn={role.name})", SUBTREE, attributes=["cn"])
if len(ldap_conn.response) > 0: if len(ldap_conn.response) > 0:
dn = ldap_conn.response[0]["dn"] dn = ldap_conn.response[0]["dn"]
if new_name: if new_name:
@ -218,13 +220,33 @@ class AuthLDAP(AuthPlugin):
else: else:
ldap_conn.delete(dn) ldap_conn.delete(dn)
except (LDAPPasswordIsMandatoryError, LDAPBindError): except LDAPPasswordIsMandatoryError:
raise BadRequest raise BadRequest
except LDAPBindError:
logger.debug(f"Could not bind to LDAP server", exc_info=True)
raise InternalServerError
def __hash(self, password):
if self.password_hash == "ARGON2":
from argon2 import PasswordHasher
return f"{{ARGON2}}{PasswordHasher().hash(password)}"
else:
from hashlib import pbkdf2_hmac, sha1
import base64
salt = os.urandom(16)
if self.password_hash == "PBKDF2":
rounds = 200000
password_hash = base64.b64encode(pbkdf2_hmac("sha512", password.encode("utf-8"), salt, rounds)).decode()
return f"{{PBKDF2-SHA512}}{rounds}${base64.b64encode(salt).decode()}${password_hash}"
else:
return f"{{SSHA}}{base64.b64encode(sha1(password + salt) + salt)}"
def _get_groups(self, uid): def _get_groups(self, uid):
groups = [] groups = []
self.ldap.connection.search( self.ldap.connection.search(
"ou=group,{}".format(self.dn), self.group_dn,
"(memberUID={})".format(uid), "(memberUID={})".format(uid),
SUBTREE, SUBTREE,
attributes=["cn"], attributes=["cn"],
@ -236,7 +258,7 @@ class AuthLDAP(AuthPlugin):
def _get_all_roles(self): def _get_all_roles(self):
self.ldap.connection.search( self.ldap.connection.search(
f"ou=group,{self.dn}", self.group_dn,
"(cn=*)", "(cn=*)",
SUBTREE, SUBTREE,
attributes=["cn", "gidNumber", "memberUid"], attributes=["cn", "gidNumber", "memberUid"],
@ -245,8 +267,7 @@ class AuthLDAP(AuthPlugin):
def _set_roles(self, user: User): def _set_roles(self, user: User):
try: try:
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret) ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
ldap_roles = self._get_all_roles() ldap_roles = self._get_all_roles()
gid_numbers = sorted(ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True) gid_numbers = sorted(ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True)
@ -255,7 +276,7 @@ class AuthLDAP(AuthPlugin):
for user_role in user.roles: for user_role in user.roles:
if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]: if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]:
ldap_conn.add( ldap_conn.add(
f"cn={user_role},ou=group,{self.dn}", f"cn={user_role},{self.group_dn}",
["posixGroup"], ["posixGroup"],
attributes={"gidNumber": gid_number}, attributes={"gidNumber": gid_number},
) )

View File

@ -113,7 +113,9 @@ def get_transaction(transaction_id) -> Transaction:
return transaction return transaction
def get_transactions(user, start=None, end=None, limit=None, offset=None, show_reversal=False, show_cancelled=True): def get_transactions(
user, start=None, end=None, limit=None, offset=None, show_reversal=False, show_cancelled=True, descending=False
):
count = None count = None
query = Transaction.query.filter((Transaction.sender_ == user) | (Transaction.receiver_ == user)) query = Transaction.query.filter((Transaction.sender_ == user) | (Transaction.receiver_ == user))
if start: if start:
@ -125,7 +127,10 @@ def get_transactions(user, start=None, end=None, limit=None, offset=None, show_r
query = query.filter(Transaction.original_ == None) query = query.filter(Transaction.original_ == None)
if not show_cancelled: if not show_cancelled:
query = query.filter(Transaction.reversal_id.is_(None)) query = query.filter(Transaction.reversal_id.is_(None))
query = query.order_by(Transaction.time) if descending:
query = query.order_by(Transaction.time.desc())
else:
query = query.order_by(Transaction.time)
if limit is not None: if limit is not None:
count = query.count() count = query.count()
query = query.limit(limit) query = query.limit(limit)

View File

@ -99,6 +99,32 @@ def set_limit(userid, current_session: Session):
return HTTP.no_content() return HTTP.no_content()
@BalancePlugin.blueprint.route("/users/balance/limit", methods=["GET", "PUT"])
@login_required(permission=permissions.SET_LIMIT)
def limits(current_session: Session):
"""Get, Modify limit of all users
Args:
current_ession: Session sent with Authorization Header
Returns:
JSON encoded array of userid with limit or HTTP-error
"""
users = userController.get_users()
if request.method == "GET":
return jsonify([{"userid": user.userid, "limit": user.get_attribute("balance_limit")} for user in users])
data = request.get_json()
try:
limit = data["limit"]
except (TypeError, KeyError):
raise BadRequest
for user in users:
balance_controller.set_limit(user, limit)
return HTTP.no_content()
@BalancePlugin.blueprint.route("/users/<userid>/balance", methods=["GET"]) @BalancePlugin.blueprint.route("/users/<userid>/balance", methods=["GET"])
@login_required(permission=permissions.SHOW) @login_required(permission=permissions.SHOW)
def get_balance(userid, current_session: Session): def get_balance(userid, current_session: Session):
@ -170,6 +196,7 @@ def get_transactions(userid, current_session: Session):
show_cancelled = request.args.get("showCancelled", True) show_cancelled = request.args.get("showCancelled", True)
limit = request.args.get("limit") limit = request.args.get("limit")
offset = request.args.get("offset") offset = request.args.get("offset")
descending = request.args.get("descending", False)
try: try:
if limit is not None: if limit is not None:
limit = int(limit) limit = int(limit)
@ -179,11 +206,20 @@ def get_transactions(userid, current_session: Session):
show_reversals = str2bool(show_reversals) show_reversals = str2bool(show_reversals)
if not isinstance(show_cancelled, bool): if not isinstance(show_cancelled, bool):
show_cancelled = str2bool(show_cancelled) show_cancelled = str2bool(show_cancelled)
if not isinstance(descending, bool):
descending = str2bool(descending)
except ValueError: except ValueError:
raise BadRequest raise BadRequest
transactions, count = balance_controller.get_transactions( transactions, count = balance_controller.get_transactions(
user, start, end, limit, offset, show_reversal=show_reversals, show_cancelled=show_cancelled user,
start,
end,
limit,
offset,
show_reversal=show_reversals,
show_cancelled=show_cancelled,
descending=descending,
) )
return {"transactions": transactions, "count": count} return {"transactions": transactions, "count": count}

View File

@ -11,6 +11,7 @@ from . import permissions, models
class EventPlugin(Plugin): class EventPlugin(Plugin):
name = "events" name = "events"
id = "dev.flaschengeist.events"
plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][EventPlugin.name]) plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][EventPlugin.name])
permissions = permissions.permissions permissions = permissions.permissions
blueprint = Blueprint(name, __name__) blueprint = Blueprint(name, __name__)

View File

@ -116,7 +116,7 @@ def get_event(event_id, with_backup=False) -> Event:
if event is None: if event is None:
raise NotFound raise NotFound
if not with_backup: if not with_backup:
return clear_backup(event) clear_backup(event)
return event return event
@ -153,7 +153,9 @@ def delete_event(event_id):
Raises: Raises:
NotFound if not found NotFound if not found
""" """
event = get_event(event_id) event = get_event(event_id, True)
for job in event.jobs:
delete_job(job)
db.session.delete(event) db.session.delete(event)
db.session.commit() db.session.commit()
@ -201,25 +203,42 @@ def update():
def delete_job(job: Job): def delete_job(job: Job):
for service in job.services:
unassign_job(service=service, notify=True)
db.session.delete(job) db.session.delete(job)
db.session.commit() db.session.commit()
def assign_to_job(job: Job, user, value): def assign_job(job: Job, user, value):
assert value > 0
service = Service.query.get((job.id, user.id_)) service = Service.query.get((job.id, user.id_))
if value < 0: if service:
if not service: service.value = value
raise BadRequest
db.session.delete(service)
else: else:
if service: service = Service(user_=user, value=value, job_=job)
service.value = value db.session.add(service)
else:
service = Service(user_=user, value=value, job_=job)
db.session.add(service)
db.session.commit() db.session.commit()
def unassign_job(job: Job = None, user=None, service=None, notify=False):
if service is None:
assert(job is not None and user is not None)
service = Service.query.get((job.id, user.id_))
else:
user = service.user_
if not service:
raise BadRequest
event_id = service.job_.event_id_
db.session.delete(service)
db.session.commit()
if notify:
EventPlugin.plugin.notify(
user, "Your assignmet was cancelled", {"event_id": event_id}
)
@scheduled @scheduled
def assign_backups(): def assign_backups():
logger.debug("Notifications") logger.debug("Notifications")

View File

@ -415,7 +415,10 @@ def update_job(event_id, job_id, current_session: Session):
user != current_session.user_ and not current_session.user_.has_permission(permissions.ASSIGN_OTHER) user != current_session.user_ and not current_session.user_.has_permission(permissions.ASSIGN_OTHER)
): ):
raise Forbidden raise Forbidden
event_controller.assign_to_job(job, user, value) if value > 0:
event_controller.assign_job(job, user, value)
else:
event_controller.unassign_job(job, user, notify=user != current_session.user_)
except (KeyError, ValueError): except (KeyError, ValueError):
raise BadRequest raise BadRequest

View File

@ -214,10 +214,37 @@ def get_drinks(identifier=None):
if identifier: if identifier:
result = pricelist_controller.get_drink(identifier, public=public) result = pricelist_controller.get_drink(identifier, public=public)
return jsonify(result)
else: else:
result = pricelist_controller.get_drinks(public=public) limit = request.args.get("limit")
logger.debug(f"GET drink {result}") offset = request.args.get("offset")
return jsonify(result) search_name = request.args.get("search_name")
search_key = request.args.get("search_key")
ingredient = request.args.get("ingredient", type=bool)
receipt = request.args.get("receipt", type=bool)
try:
if limit is not None:
limit = int(limit)
if offset is not None:
offset = int(offset)
if ingredient is not None:
ingredient = bool(ingredient)
if receipt is not None:
receipt = bool(receipt)
except ValueError:
raise BadRequest
drinks, count = pricelist_controller.get_drinks(
public=public,
limit=limit,
offset=offset,
search_name=search_name,
search_key=search_key,
ingredient=ingredient,
receipt=receipt,
)
logger.debug(f"GET drink {drinks}, {count}")
# return jsonify({"drinks": drinks, "count": count})
return jsonify({"drinks": drinks, "count": count})
@PriceListPlugin.blueprint.route("/drinks/search/<string:name>", methods=["GET"]) @PriceListPlugin.blueprint.route("/drinks/search/<string:name>", methods=["GET"])
@ -567,6 +594,7 @@ def get_columns(userid, current_session):
userController.persist() userController.persist()
return no_content() return no_content()
@PriceListPlugin.blueprint.route("/users/<userid>/pricecalc_columns_order", methods=["GET", "PUT"]) @PriceListPlugin.blueprint.route("/users/<userid>/pricecalc_columns_order", methods=["GET", "PUT"])
@login_required() @login_required()
def get_columns_order(userid, current_session): def get_columns_order(userid, current_session):

View File

@ -76,16 +76,17 @@ class DrinkIngredient(db.Model, ModelSerializeMixin):
id: int = db.Column("id", db.Integer, primary_key=True) id: int = db.Column("id", db.Integer, primary_key=True)
volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False)
ingredient_id: int = db.Column(db.Integer, db.ForeignKey("drink.id")) ingredient_id: int = db.Column(db.Integer, db.ForeignKey("drink.id"))
# drink_ingredient: Drink = db.relationship("Drink") cost_per_volume: float
# price: float = 0 name: str
_drink_ingredient: Drink = db.relationship("Drink")
@property
def cost_per_volume(self):
return self._drink_ingredient.cost_per_volume if self._drink_ingredient else None
# @property @property
# def price(self): def name(self):
# try: return self._drink_ingredient.name if self._drink_ingredient else None
# return self.drink_ingredient.cost_price_pro_volume * self.volume
# except AttributeError:
# pass
class Ingredient(db.Model, ModelSerializeMixin): class Ingredient(db.Model, ModelSerializeMixin):

View File

@ -131,13 +131,44 @@ def _create_public_drink(drink):
return None return None
def get_drinks(name=None, public=False): def get_drinks(
name=None, public=False, limit=None, offset=None, search_name=None, search_key=None, ingredient=False, receipt=None
):
count = None
if name: if name:
drinks = Drink.query.filter(Drink.name.contains(name)).all() query = Drink.query.filter(Drink.name.contains(name))
drinks = Drink.query.all() else:
query = Drink.query
if ingredient:
query = query.filter(Drink.cost_per_volume >= 0)
if receipt:
query = query.filter(Drink.volumes.any(DrinkPriceVolume.ingredients != None))
if search_name:
if search_key == "name":
query = query.filter(Drink.name.contains(search_name))
elif search_key == "article_id":
query = query.filter(Drink.article_id.contains(search_name))
elif search_key == "drink_type":
query = query.filter(Drink.type.has(DrinkType.name.contains(search_name)))
elif search_key == "tags":
query = query.filter(Drink.tags.any(Tag.name.contains(search_name)))
else:
query = query.filter(
(Drink.name.contains(search_name))
| (Drink.article_id.contains(search_name))
| (Drink.type.has(DrinkType.name.contains(search_name)))
| (Drink.tags.any(Tag.name.contains(search_name)))
)
if limit is not None:
count = query.count()
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
drinks = query.all()
if public: if public:
return [_create_public_drink(drink) for drink in drinks if _create_public_drink(drink)] return [_create_public_drink(drink) for drink in drinks if _create_public_drink(drink)], count
return drinks return drinks, count
def get_drink(identifier, public=False): def get_drink(identifier, public=False):
@ -209,6 +240,9 @@ def set_volumes(volumes):
def delete_drink(identifier): def delete_drink(identifier):
drink = get_drink(identifier) drink = get_drink(identifier)
if drink.uuid:
path = config["pricelist"]["path"]
delete_picture(f"{path}/{drink.uuid}")
db.session.delete(drink) db.session.delete(drink)
db.session.commit() db.session.commit()
@ -315,6 +349,10 @@ def delete_price(identifier):
def set_drink_ingredient(data): def set_drink_ingredient(data):
allowed_keys = DrinkIngredient().serialize().keys() allowed_keys = DrinkIngredient().serialize().keys()
values = {key: value for key, value in data.items() if key in allowed_keys} values = {key: value for key, value in data.items() if key in allowed_keys}
if "cost_per_volume" in values:
values.pop("cost_per_volume")
if "name" in values:
values.pop("name")
ingredient_id = values.pop("id", -1) ingredient_id = values.pop("id", -1)
if ingredient_id < 0: if ingredient_id < 0:
drink_ingredient = DrinkIngredient(**values) drink_ingredient = DrinkIngredient(**values)

View File

@ -231,3 +231,21 @@ def notifications(current_session):
def remove_notifications(nid, current_session): def remove_notifications(nid, current_session):
userController.delete_notification(nid, current_session.user_) userController.delete_notification(nid, current_session.user_)
return no_content() return no_content()
@UsersPlugin.blueprint.route("/users/<userid>/shortcuts", methods=["GET", "PUT"])
@login_required()
def shortcuts(userid, current_session):
if userid != current_session.user_.userid:
raise Forbidden
user = userController.get_user(userid)
if request.method == "GET":
return jsonify(user.get_attribute("users_link_shortcuts", []))
else:
data = request.get_json()
if not isinstance(data, list) or not all(isinstance(n, dict) for n in data):
raise BadRequest
user.set_attribute("users_link_shortcuts", data)
userController.persist()
return no_content()

114
readme.md
View File

@ -1,9 +1,16 @@
# Flaschengeist # Flaschengeist
This is the backend of the Flaschengeist.
## Installation ## Installation
### Requirements ### Requirements
- mysql or mariadb - `mysql` or `mariadb`
- python 3.6+ - maybe `libmariadb` development files[1]
- python 3.7+
[1] By default Flaschengeist uses mysql as database backend, if you are on Windows Flaschengeist uses `PyMySQL`, but on
Linux / Mac the faster `mysqlclient` is used, if it is not already installed installing from pypi requires the
development files for `libmariadb` to be present on your system.
### Install python files ### Install python files
pip3 install --user . pip3 install --user .
or with ldap support or with ldap support
@ -30,9 +37,21 @@ Configuration is done within the a `flaschengeist.toml`file, you can copy the on
1. `~/.config/` 1. `~/.config/`
2. A custom path and set environment variable `FLASCHENGEIST_CONF` 2. A custom path and set environment variable `FLASCHENGEIST_CONF`
Change at least the database parameters! Uncomment and change at least all the database parameters!
### Database installation ### Database installation
The user needs to have full permissions to the database.
If not you need to create user and database manually do (or similar on Windows):
(
echo "CREATE DATABASE flaschengeist;"
echo "CREATE USER 'flaschengeist'@'localhost' IDENTIFIED BY 'flaschengeist';"
echo "GRANT ALL PRIVILEGES ON 'flaschengeist'.* TO 'flaschengeist'@'localhost';"
echo "FLUSH PRIVILEGES;"
) | sudo mysql
Then you can install the database tables and initial entries:
run_flaschengeist install run_flaschengeist install
### Run ### Run
@ -41,6 +60,8 @@ or with debug messages:
run_flaschengeist run --debug run_flaschengeist run --debug
This will run the backend on http://localhost:5000
## Tests ## Tests
$ pip install '.[test]' $ pip install '.[test]'
$ pytest $ pytest
@ -53,89 +74,4 @@ Or with html output (open `htmlcov/index.html` in a browser):
$ coverage html $ coverage html
## Development ## Development
### Code Style Please refer to our [development wiki](https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/Development).
We enforce you to use PEP 8 code style with a line length of 120 as used by Black.
See also [Black Code Style](https://github.com/psf/black/blob/master/docs/the_black_code_style.md).
#### Code formatting
We use [Black](https://github.com/psf/black) as the code formatter.
Installation:
pip install black
Usage:
black -l 120 DIRECTORY_OR_FILE
### Misc
#### Git blame
When using `git blame` use this to ignore the code formatting commits:
$ git blame FILE.py --ignore-revs-file .git-blame-ignore-revs
Or if you just want to use `git blame`, configure git like this:
$ git config blame.ignoreRevsFile .git-blame-ignore-revs
#### Ignore changes on config
git update-index --assume-unchanged flaschengeist/flaschengeist.toml
## Plugin Development
### File Structure
flaschengeist-example-plugin
|> __init__.py
|> model.py
|> setup.py
### Files
#### \_\_init\_\_.py
from flask import Blueprint
from flaschengeist.modules import Plugin
example_bp = Blueprint("example", __name__, url_prefix="/example")
permissions = ["example_hello"]
class PluginExample(Plugin):
def __init__(self, conf):
super().__init__(blueprint=example_bp, permissions=permissions)
def install(self):
from flaschengeist.system.database import db
import .model
db.create_all()
db.session.commit()
@example_bp.route("/hello", methods=['GET'])
@login_required(roles=['example_hello'])
def __hello(id, **kwargs):
return "Hello"
#### model.py
Optional, only needed if you need your own models (database)
from flaschengeist.system.database import db
class ExampleModel(db.Model):
"""Example Model"""
__tablename__ = 'example'
id = db.Column(db.Integer, primary_key=True)
description = db.Column(db.String(240))
#### setup.py
from setuptools import setup, find_packages
setup(
name="flaschengeist-example-plugin",
version="0.0.0-dev",
packages=find_packages(),
install_requires=[
"flaschengeist >= 2",
],
entry_points={
"flaschengeist.plugin": [
"example = flaschengeist-example-plugin:ExampleModel"
]
},
)

View File

@ -17,7 +17,7 @@ class PrefixMiddleware(object):
def __call__(self, environ, start_response): def __call__(self, environ, start_response):
if environ["PATH_INFO"].startswith(self.prefix): if environ["PATH_INFO"].startswith(self.prefix):
environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix):] environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix) :]
environ["SCRIPT_NAME"] = self.prefix environ["SCRIPT_NAME"] = self.prefix
return self.app(environ, start_response) return self.app(environ, start_response)
else: else:
@ -83,19 +83,19 @@ class InterfaceGenerator:
import typing import typing
if ( if (
inspect.ismodule(module[1]) inspect.ismodule(module[1])
and module[1].__name__.startswith(self.basename) and module[1].__name__.startswith(self.basename)
and module[1].__name__ not in self.known and module[1].__name__ not in self.known
): ):
self.known.append(module[1].__name__) self.known.append(module[1].__name__)
for cls in inspect.getmembers(module[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)): for cls in inspect.getmembers(module[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)):
self.walker(cls) self.walker(cls)
elif ( elif (
inspect.isclass(module[1]) inspect.isclass(module[1])
and module[1].__module__.startswith(self.basename) and module[1].__module__.startswith(self.basename)
and module[0] not in self.classes and module[0] not in self.classes
and not module[0].startswith("_") and not module[0].startswith("_")
and hasattr(module[1], "__annotations__") and hasattr(module[1], "__annotations__")
): ):
self.this_type = module[0] self.this_type = module[0]
print("\n\n" + module[0] + "\n") print("\n\n" + module[0] + "\n")
@ -156,12 +156,14 @@ def export(arguments):
app = create_app() app = create_app()
with app.app_context(): with app.app_context():
gen = InterfaceGenerator(arguments.namespace, arguments.file) gen = InterfaceGenerator(arguments.namespace, arguments.file)
gen.run(models) if not arguments.no_core:
if arguments.plugins: gen.run(models)
if arguments.plugins is not None:
for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"): for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"):
plg = entry_point.load() if len(arguments.plugins) == 0 or entry_point.name in arguments.plugins:
if hasattr(plg, "models") and plg.models is not None: plg = entry_point.load()
gen.run(plg.models) if hasattr(plg, "models") and plg.models is not None:
gen.run(plg.models)
gen.write() gen.write()
@ -183,7 +185,12 @@ if __name__ == "__main__":
parser_export.set_defaults(func=export) parser_export.set_defaults(func=export)
parser_export.add_argument("--file", help="Filename where to save", default="flaschengeist.d.ts") parser_export.add_argument("--file", help="Filename where to save", default="flaschengeist.d.ts")
parser_export.add_argument("--namespace", help="Namespace of declarations", default="FG") parser_export.add_argument("--namespace", help="Namespace of declarations", default="FG")
parser_export.add_argument("--plugins", help="Also export plugins", action="store_true") parser_export.add_argument(
"--no-core",
help="Do not export core declarations (only useful in conjunction with --plugins)",
action="store_true",
)
parser_export.add_argument("--plugins", help="Also export plugins (none means all)", nargs="*")
args = parser.parse_args() args = parser.parse_args()
args.func(args) args.func(args)

View File

@ -41,7 +41,12 @@ setup(
"werkzeug", "werkzeug",
mysql_driver, mysql_driver,
], ],
extras_require={"ldap": ["flask_ldapconn", "ldap3"], "pricelist": ["pillow"], "test": ["pytest", "coverage"]}, extras_require={
"ldap": ["flask_ldapconn", "ldap3"],
"argon": ["argon2-cffi"],
"pricelist": ["pillow"],
"test": ["pytest", "coverage"],
},
entry_points={ entry_points={
"flaschengeist.plugin": [ "flaschengeist.plugin": [
# Authentication providers # Authentication providers