mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04:00
Add LDAP authentication support (v2, onto dev) (#803)
* Add LDAP authentication support * Add test for LDAP authentication
This commit is contained in:
parent
32c864c703
commit
56d9cafb68
1
.github/workflows/test-all.yml
vendored
1
.github/workflows/test-all.yml
vendored
@ -57,6 +57,7 @@ jobs:
|
|||||||
#----------------------------------------------
|
#----------------------------------------------
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
sudo apt-get install libsasl2-dev libldap2-dev libssl-dev
|
||||||
poetry install
|
poetry install
|
||||||
poetry add "psycopg2-binary==2.8.6"
|
poetry add "psycopg2-binary==2.8.6"
|
||||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
||||||
|
@ -128,6 +128,10 @@ services:
|
|||||||
| POSTGRES_PORT | 5432 | Postgres database port |
|
| POSTGRES_PORT | 5432 | Postgres database port |
|
||||||
| POSTGRES_DB | mealie | Postgres database name |
|
| POSTGRES_DB | mealie | Postgres database name |
|
||||||
| TOKEN_TIME | 2 | The time in hours that a login/auth token is valid |
|
| TOKEN_TIME | 2 | The time in hours that a login/auth token is valid |
|
||||||
|
| 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)`) |
|
||||||
| RECIPE_PUBLIC | True | Default Recipe Settings - Make Recipe Public |
|
| RECIPE_PUBLIC | True | Default Recipe Settings - Make Recipe Public |
|
||||||
| RECIPE_SHOW_NUTRITION | True | Default Recipe Settings - Show Recipe Nutrition |
|
| RECIPE_SHOW_NUTRITION | True | Default Recipe Settings - Show Recipe Nutrition |
|
||||||
| RECIPE_SHOW_ASSETS | True | Default Recipe Settings - Show Recipe Assets |
|
| RECIPE_SHOW_ASSETS | True | Default Recipe Settings - Show Recipe Assets |
|
||||||
|
@ -151,6 +151,11 @@ class AppSettings(BaseSettings):
|
|||||||
DEFAULT_EMAIL: str = "changeme@email.com"
|
DEFAULT_EMAIL: str = "changeme@email.com"
|
||||||
DEFAULT_PASSWORD: str = "MyPassword"
|
DEFAULT_PASSWORD: str = "MyPassword"
|
||||||
|
|
||||||
|
LDAP_AUTH_ENABLED: bool = False
|
||||||
|
LDAP_SERVER_URL: str = None
|
||||||
|
LDAP_BIND_TEMPLATE: str = None
|
||||||
|
LDAP_ADMIN_FILTER: str = None
|
||||||
|
|
||||||
SCHEDULER_DATABASE = f"sqlite:///{app_dirs.DATA_DIR.joinpath('scheduler.db')}"
|
SCHEDULER_DATABASE = f"sqlite:///{app_dirs.DATA_DIR.joinpath('scheduler.db')}"
|
||||||
|
|
||||||
TOKEN_TIME: int = 2 # Time in Hours
|
TOKEN_TIME: int = 2 # Time in Hours
|
||||||
|
@ -11,6 +11,44 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
|
|
||||||
|
|
||||||
|
def user_from_ldap(session, username: str, password: str) -> UserInDB:
|
||||||
|
"""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
|
||||||
|
|
||||||
|
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(session, username, "username", any_case=True)
|
||||||
|
if not user:
|
||||||
|
user = db.users.create(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"username": username,
|
||||||
|
"password": "LDAP",
|
||||||
|
# Fill the next two values with something unique and vaguely
|
||||||
|
# relevant
|
||||||
|
"full_name": username,
|
||||||
|
"email": username,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.LDAP_ADMIN_FILTER:
|
||||||
|
user.admin = len(conn.search_s(user_dn, ldap.SCOPE_BASE, settings.LDAP_ADMIN_FILTER, [])) > 0
|
||||||
|
db.users.update(session, user.id, user)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: dict(), expires_delta: timedelta = None) -> str:
|
def create_access_token(data: dict(), expires_delta: timedelta = None) -> str:
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
expires_delta = expires_delta or timedelta(hours=settings.TOKEN_TIME)
|
expires_delta = expires_delta or timedelta(hours=settings.TOKEN_TIME)
|
||||||
@ -31,6 +69,8 @@ def authenticate_user(session, email: str, password: str) -> UserInDB:
|
|||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
user = db.users.get(session, email, "username", any_case=True)
|
user = db.users.get(session, email, "username", any_case=True)
|
||||||
|
if settings.LDAP_AUTH_ENABLED and (not user or user.password == "LDAP"):
|
||||||
|
return user_from_ldap(session, email, password)
|
||||||
if not user:
|
if not user:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
1005
poetry.lock
generated
1005
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -34,6 +34,7 @@ pathvalidate = "^2.4.1"
|
|||||||
apprise = "0.9.3"
|
apprise = "0.9.3"
|
||||||
recipe-scrapers = "^13.2.7"
|
recipe-scrapers = "^13.2.7"
|
||||||
psycopg2-binary = {version = "^2.9.1", optional = true}
|
psycopg2-binary = {version = "^2.9.1", optional = true}
|
||||||
|
python-ldap = "^3.3.0"
|
||||||
gunicorn = "^20.1.0"
|
gunicorn = "^20.1.0"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
@ -67,3 +68,4 @@ skip_empty = true
|
|||||||
|
|
||||||
[tool.poetry.extras]
|
[tool.poetry.extras]
|
||||||
pgsql = ["psycopg2-binary"]
|
pgsql = ["psycopg2-binary"]
|
||||||
|
ldap = ["python-ldap"]
|
||||||
|
@ -33,4 +33,10 @@ RECIPE_SHOW_NUTRITION=False
|
|||||||
RECIPE_SHOW_ASSETS=False
|
RECIPE_SHOW_ASSETS=False
|
||||||
RECIPE_LANDSCAPE_VIEW=False
|
RECIPE_LANDSCAPE_VIEW=False
|
||||||
RECIPE_DISABLE_COMMENTS=False
|
RECIPE_DISABLE_COMMENTS=False
|
||||||
RECIPE_DISABLE_AMOUNT=False
|
RECIPE_DISABLE_AMOUNT=False
|
||||||
|
|
||||||
|
# Configuration for authentication via an external LDAP server
|
||||||
|
LDAP_AUTH_ENABLED=False
|
||||||
|
LDAP_SERVER_URL=None
|
||||||
|
LDAP_BIND_TEMPLATE=None
|
||||||
|
LDAP_ADMIN_FILTER=None
|
||||||
|
@ -2,6 +2,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
from mealie.core import security
|
from mealie.core import security
|
||||||
from mealie.routes.deps import validate_file_token
|
from mealie.routes.deps import validate_file_token
|
||||||
|
from mealie.core.config import settings
|
||||||
|
from mealie.db.db_setup import create_session
|
||||||
|
|
||||||
|
|
||||||
def test_create_file_token():
|
def test_create_file_token():
|
||||||
@ -9,3 +11,37 @@ def test_create_file_token():
|
|||||||
file_token = security.create_file_token(file_path)
|
file_token = security.create_file_token(file_path)
|
||||||
|
|
||||||
assert file_path == validate_file_token(file_token)
|
assert file_path == validate_file_token(file_token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ldap_authentication_mocked(monkeypatch):
|
||||||
|
import ldap
|
||||||
|
|
||||||
|
user = "testinguser"
|
||||||
|
password = "testingpass"
|
||||||
|
bind_template = "cn={},dc=example,dc=com"
|
||||||
|
admin_filter = "(memberOf=cn=admins,dc=example,dc=com)"
|
||||||
|
monkeypatch.setattr(settings, "LDAP_AUTH_ENABLED", True)
|
||||||
|
monkeypatch.setattr(settings, "LDAP_SERVER_URL", "") # Not needed due to mocking
|
||||||
|
monkeypatch.setattr(settings, "LDAP_BIND_TEMPLATE", bind_template)
|
||||||
|
monkeypatch.setattr(settings, "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)
|
||||||
|
result = security.authenticate_user(create_session(), user, password)
|
||||||
|
assert result is not False
|
||||||
|
assert result.username == user
|
||||||
|
Loading…
x
Reference in New Issue
Block a user