diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 776cd00f8b9e..a8f33a7950e2 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -55,6 +55,7 @@ jobs: #---------------------------------------------- - name: Install dependencies run: | + sudo apt-get install libsasl2-dev libldap2-dev libssl-dev poetry install poetry add "psycopg2-binary==2.8.6" # if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' diff --git a/docs/docs/documentation/getting-started/installation/backend-config.md b/docs/docs/documentation/getting-started/installation/backend-config.md index 52550fd7ccdc..1a671e9cae0c 100644 --- a/docs/docs/documentation/getting-started/installation/backend-config.md +++ b/docs/docs/documentation/getting-started/installation/backend-config.md @@ -17,6 +17,8 @@ | TZ | UTC | Must be set to get correct date/time on the server | + + ### Database | Variables | Default | Description | @@ -49,3 +51,13 @@ Changing the webworker settings may cause unforeseen memory leak issues with Mea | WORKERS_PER_CORE | 1 | Set the number of workers to the number of CPU cores multiplied by this value (Value \* CPUs). More info [here][workers_per_core] | | MAX_WORKERS | 1 | Set the maximum number of workers to use. Default is not set meaning unlimited. More info [here][max_workers] | | WEB_CONCURRENCY | 1 | Override the automatic definition of number of workers. More info [here][web_concurrency] | + + +### LDAP + +| Variables | Default | Description | +| ------------------ | :-----: | ------------------------------------------------------------------------------------------------------------------ | +| LDAP_AUTH_ENABLED | False | Authenticate via an external LDAP server in addidion to built-in Mealie auth | +| LDAP_SERVER_URL | None | LDAP server URL (e.g. ldap://ldap.example.com) | +| LDAP_BIND_TEMPLATE | None | Templated DN for users, `{}` will be replaced with the username (e.g. `cn={},dc=example,dc=com`) | +| LDAP_ADMIN_FILTER | None | Optional LDAP filter, which tells Mealie the LDAP user is an admin (e.g. `(memberOf=cn=admins,dc=example,dc=com)`) | \ No newline at end of file diff --git a/docs/docs/overrides/home.html b/docs/docs/overrides/home.html index 6906317c43c4..886d50c5e9ec 100644 --- a/docs/docs/overrides/home.html +++ b/docs/docs/overrides/home.html @@ -1,355 +1,421 @@ -{% extends "main.html" %} -{% block tabs %} -{{ super() }} +{% extends "main.html" %} {% block tabs %} {{ super() }}
-
-
-
- -
-
-

- Mealie -

-

- A self-hosted recipe manager and meal planner with a RestAPI backend and a - reactive frontend application built in Vue for a pleasant user experience for the - whole family. -

- - Get started - - - View the Demo - -
+
+
+
+ +
+
+

Mealie

+

+ A self-hosted recipe manager and meal planner with a RestAPI backend + and a reactive frontend application built in Vue for a pleasant user + experience for the whole family. +

+ + Get started + + + View the Demo +
+
+
+
+

+ + + + Import Recipes +

+

+ Quickly and easily import recipes from sites around the web using the + built in recipe scraper. +

+
+
+

+ + + + Automatic Backups +

+

+ Keep your data safe with automatic backups in any format supported by + Jinja2 templates +

+
+
+

+ + + + Rich User Interface +

+

+ Use a beautiful and intuitive user interface to create, edit, and delete + recipes. Recipe editor supports markdown syntax +

+
+
+

+ + + + Meal Planner +

+

Create Meal Plans for the week, month, or year!

+
+
+ -
+
-

- - - - Import Recipes -

-

- Quickly and easily import recipes from sites around the web using the built in recipe scrapper. -

+

+ + + + Users +

+

+ Add new users with sign-up links or simply create a new user in the + admin panel. +

-

- - - - Automatic Backups -

-

- Keep your data safe with automatic backups in any format supported by Jinja2 templates -

+

+ + + + Groups +

+

+ Sort users into groups to share recipes with the whole family, but keep + your Meal Plans separate. +

-

- - - - Rich User Interface -

-

Use a beautiful and intuitive user interface to create, edit, and delete recipes. Recipe editor supports markdown syntax

+

+ + + + Webhooks +

+

+ Schedule webhooks to send notifications to 3rd party services with + todays Meal Plan data. +

-

- - - - Meal Planner -

-

Create Meal Plans for the week, month, or year!

+

+ + + + Open API +

+

+ API Driven application gives you full control of the backend + server with interactive documentation +

-
- - -
-
-

- - - - Users -

-

- Add new users with sign-up links or simply create a new user in the admin panel. -

-
-
-

- - - - Groups -

-

- Sort users into groups to share recipes with the whole family, but keep your Meal Plans separate. -

-
-
-

- - - - Webhooks -

-

Schedule webhooks to send notifications to 3rd party services with todays Meal Plan data.

-
-
-

- - - - Open API -

-

API Driven application gives you full control of the backend server with interactive documentation

-
-
+
- -{% endblock %} -{% block content %}{% endblock %} -{% block footer %}{% endblock %} \ No newline at end of file +{% endblock %} {% block content %}{% endblock %} {% block footer %}{% endblock +%} diff --git a/frontend/components/Domain/Recipe/RecipeCategoryTagDialog.vue b/frontend/components/Domain/Recipe/RecipeCategoryTagDialog.vue index 15196252f53c..e3e63090a59a 100644 --- a/frontend/components/Domain/Recipe/RecipeCategoryTagDialog.vue +++ b/frontend/components/Domain/Recipe/RecipeCategoryTagDialog.vue @@ -21,7 +21,13 @@ - + diff --git a/mealie/core/security.py b/mealie/core/security.py index 7790e8346731..8824ab776db1 100644 --- a/mealie/core/security.py +++ b/mealie/core/security.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import secrets from datetime import datetime, timedelta from pathlib import Path @@ -6,16 +8,17 @@ from jose import jwt from passlib.context import CryptContext from mealie.core.config import get_app_settings +from mealie.db.data_access_layer.access_model_factory import Database from mealie.db.database import get_database from mealie.schema.user import PrivateUser -settings = get_app_settings() - pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") ALGORITHM = "HS256" def create_access_token(data: dict(), expires_delta: timedelta = None) -> str: + settings = get_app_settings() + to_encode = data.copy() expires_delta = expires_delta or timedelta(hours=settings.TOKEN_TIME) @@ -35,18 +38,61 @@ def create_recipe_slug_token(file_path: str) -> str: return create_access_token(token_data, expires_delta=timedelta(minutes=30)) -def authenticate_user(session, email: str, password: str) -> PrivateUser: - db = get_database(session) +def user_from_ldap(db: Database, session, username: str, password: str) -> PrivateUser: + """Given a username and password, tries to authenticate by BINDing to an + LDAP server + If the BIND succeeds, it will either create a new user of that username on + the server or return an existing one. + Returns False on failure. + """ + import ldap + + settings = get_app_settings() + + conn = ldap.initialize(settings.LDAP_SERVER_URL) + user_dn = settings.LDAP_BIND_TEMPLATE.format(username) + try: + conn.simple_bind_s(user_dn, password) + except (ldap.INVALID_CREDENTIALS, ldap.NO_SUCH_OBJECT): + return False + + user = db.users.get_one(username, "username", any_case=True) + if not user: + user = db.users.create( + { + "username": username, + "password": "LDAP", + # Fill the next two values with something unique and vaguely + # relevant + "full_name": username, + "email": username, + "admin": False, + }, + ) + + if settings.LDAP_ADMIN_FILTER: + user.admin = len(conn.search_s(user_dn, ldap.SCOPE_BASE, settings.LDAP_ADMIN_FILTER, [])) > 0 + db.users.update(user.id, user) + + return user + + +def authenticate_user(session, email: str, password: str) -> PrivateUser | False: + settings = get_app_settings() + + db = get_database(session) user: PrivateUser = db.users.get(email, "email", any_case=True) if not user: user = db.users.get(email, "username", any_case=True) - if not user: + + if settings.LDAP_AUTH_ENABLED and (not user or user.password == "LDAP"): + return user_from_ldap(db, session, email, password) + + if not user or not verify_password(password, user.password): return False - if not verify_password(password, user.password): - return False return user diff --git a/mealie/core/settings/settings.py b/mealie/core/settings/settings.py index 34114dcd1d4e..e4dad3119750 100644 --- a/mealie/core/settings/settings.py +++ b/mealie/core/settings/settings.py @@ -83,6 +83,25 @@ class AppSettings(BaseSettings): return "" not in required and None not in required + # =============================================== + # LDAP Configuration + + LDAP_AUTH_ENABLED: bool = False + LDAP_SERVER_URL: str = None + LDAP_BIND_TEMPLATE: str = None + LDAP_ADMIN_FILTER: str = None + + @property + def LDAP_ENABLED(self) -> bool: + """Validates LDAP settings are all set""" + required = { + self.LDAP_SERVER_URL, + self.LDAP_BIND_TEMPLATE, + self.LDAP_ADMIN_FILTER, + } + + return "" not in required and None not in required and self.LDAP_AUTH_ENABLED + class Config: arbitrary_types_allowed = True diff --git a/mealie/services/image/image.py b/mealie/services/image/image.py index 8b87940586e2..baab21ec668d 100644 --- a/mealie/services/image/image.py +++ b/mealie/services/image/image.py @@ -43,6 +43,8 @@ def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path: def scrape_image(image_url: str, slug: str) -> Path: logger.info(f"Image URL: {image_url}") + _FIREFOX_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0" + if isinstance(image_url, str): # Handles String Types pass @@ -54,7 +56,7 @@ def scrape_image(image_url: str, slug: str) -> Path: all_image_requests = [] for url in image_url: try: - r = requests.get(url, stream=True, headers={"User-Agent": ""}) + r = requests.get(url, stream=True, headers={"User-Agent": _FIREFOX_UA}) except Exception: logger.exception("Image {url} could not be requested") continue @@ -72,7 +74,7 @@ def scrape_image(image_url: str, slug: str) -> Path: filename = Recipe(slug=slug).image_dir.joinpath(filename) try: - r = requests.get(image_url, stream=True) + r = requests.get(image_url, stream=True, headers={"User-Agent": _FIREFOX_UA}) except Exception: logger.exception("Fatal Image Request Exception") return None diff --git a/poetry.lock b/poetry.lock index 1c07a82813ca..130b5b60adfd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -813,6 +813,17 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "pyasn1-modules" +version = "0.2.8" +description = "A collection of ASN.1-based protocols modules." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pyasn1 = ">=0.4.6,<0.5.0" + [[package]] name = "pycodestyle" version = "2.7.0" @@ -1016,6 +1027,18 @@ cryptography = ["cryptography (>=3.4.0)"] pycrypto = ["pycrypto (>=2.6.0,<2.7.0)", "pyasn1"] pycryptodome = ["pycryptodome (>=3.3.1,<4.0.0)", "pyasn1"] +[[package]] +name = "python-ldap" +version = "3.3.1" +description = "Python modules for implementing LDAP clients" +category = "main" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" + +[package.dependencies] +pyasn1 = ">=0.3.7" +pyasn1_modules = ">=0.1.5" + [[package]] name = "python-multipart" version = "0.0.5" @@ -1423,7 +1446,7 @@ pgsql = ["psycopg2-binary"] [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "597bcfac6b50f5f6e203db40e05546b1a9aaf4c8438790d233424cf66fc84d19" +content-hash = "cd88ddf0b5bd0a771a2931c82acc8923fab2a743269e7ac0ae323eab9f1b38d5" [metadata.files] aiofiles = [ @@ -2079,6 +2102,21 @@ pyasn1 = [ {file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"}, {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, ] +pyasn1-modules = [ + {file = "pyasn1-modules-0.2.8.tar.gz", hash = "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e"}, + {file = "pyasn1_modules-0.2.8-py2.4.egg", hash = "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199"}, + {file = "pyasn1_modules-0.2.8-py2.5.egg", hash = "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405"}, + {file = "pyasn1_modules-0.2.8-py2.6.egg", hash = "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb"}, + {file = "pyasn1_modules-0.2.8-py2.7.egg", hash = "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8"}, + {file = "pyasn1_modules-0.2.8-py2.py3-none-any.whl", hash = "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74"}, + {file = "pyasn1_modules-0.2.8-py3.1.egg", hash = "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d"}, + {file = "pyasn1_modules-0.2.8-py3.2.egg", hash = "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45"}, + {file = "pyasn1_modules-0.2.8-py3.3.egg", hash = "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4"}, + {file = "pyasn1_modules-0.2.8-py3.4.egg", hash = "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811"}, + {file = "pyasn1_modules-0.2.8-py3.5.egg", hash = "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed"}, + {file = "pyasn1_modules-0.2.8-py3.6.egg", hash = "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0"}, + {file = "pyasn1_modules-0.2.8-py3.7.egg", hash = "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd"}, +] pycodestyle = [ {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, @@ -2168,6 +2206,9 @@ python-jose = [ {file = "python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a"}, {file = "python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a"}, ] +python-ldap = [ + {file = "python-ldap-3.3.1.tar.gz", hash = "sha256:4711cacf013e298754abd70058ccc995758177fb425f1c2d30e71adfc1d00aa5"}, +] python-multipart = [ {file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"}, ] diff --git a/pyproject.toml b/pyproject.toml index d140d41cc5a6..ced0a86baf0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ psycopg2-binary = {version = "^2.9.1", optional = true} gunicorn = "^20.1.0" emails = "^0.6" python-i18n = "^0.3.9" +python-ldap = "^3.3.1" [tool.poetry.dev-dependencies] pylint = "^2.6.0" diff --git a/template.env b/template.env index 63e3ea7c0be1..004a466be9ff 100644 --- a/template.env +++ b/template.env @@ -34,3 +34,9 @@ LANG=en-US # SMTP_USER="" # SMTP_PASSWORD="" +# Configuration for authentication via an external LDAP server +LDAP_AUTH_ENABLED=False +LDAP_SERVER_URL=None +LDAP_BIND_TEMPLATE=None +LDAP_ADMIN_FILTER=None + diff --git a/tests/unit_tests/test_security.py b/tests/unit_tests/test_security.py index c544bf4986b5..333cfd6892c3 100644 --- a/tests/unit_tests/test_security.py +++ b/tests/unit_tests/test_security.py @@ -1,7 +1,12 @@ from pathlib import Path +from pytest import MonkeyPatch + from mealie.core import security +from mealie.core.config import get_app_settings from mealie.core.dependencies import validate_file_token +from mealie.db.db_setup import create_session +from tests.utils.factories import random_string def test_create_file_token(): @@ -9,3 +14,39 @@ def test_create_file_token(): file_token = security.create_file_token(file_path) assert file_path == validate_file_token(file_token) + + +def test_ldap_authentication_mocked(monkeypatch: MonkeyPatch): + import ldap + + user = random_string(10) + password = random_string(10) + bind_template = "cn={},dc=example,dc=com" + admin_filter = "(memberOf=cn=admins,dc=example,dc=com)" + monkeypatch.setenv("LDAP_AUTH_ENABLED", "true") + monkeypatch.setenv("LDAP_SERVER_URL", "") # Not needed due to mocking + monkeypatch.setenv("LDAP_BIND_TEMPLATE", bind_template) + monkeypatch.setenv("LDAP_ADMIN_FILTER", admin_filter) + + class LdapConnMock: + def simple_bind_s(self, dn, bind_pw): + assert dn == bind_template.format(user) + return bind_pw == password + + def search_s(self, dn, scope, filter, attrlist): + assert attrlist == [] + assert filter == admin_filter + assert dn == bind_template.format(user) + assert scope == ldap.SCOPE_BASE + return [()] + + def ldap_initialize_mock(url): + assert url == "" + return LdapConnMock() + + monkeypatch.setattr(ldap, "initialize", ldap_initialize_mock) + + get_app_settings.cache_clear() + result = security.authenticate_user(create_session(), user, password) + assert result is not False + assert result.username == user