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
|
||||
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'
|
||||
|
@ -128,6 +128,10 @@ services:
|
||||
| POSTGRES_PORT | 5432 | Postgres database port |
|
||||
| POSTGRES_DB | mealie | Postgres database name |
|
||||
| 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_SHOW_NUTRITION | True | Default Recipe Settings - Show Recipe Nutrition |
|
||||
| RECIPE_SHOW_ASSETS | True | Default Recipe Settings - Show Recipe Assets |
|
||||
|
@ -151,6 +151,11 @@ class AppSettings(BaseSettings):
|
||||
DEFAULT_EMAIL: str = "changeme@email.com"
|
||||
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')}"
|
||||
|
||||
TOKEN_TIME: int = 2 # Time in Hours
|
||||
|
@ -11,6 +11,44 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
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:
|
||||
to_encode = data.copy()
|
||||
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:
|
||||
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:
|
||||
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"
|
||||
recipe-scrapers = "^13.2.7"
|
||||
psycopg2-binary = {version = "^2.9.1", optional = true}
|
||||
python-ldap = "^3.3.0"
|
||||
gunicorn = "^20.1.0"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
@ -67,3 +68,4 @@ skip_empty = true
|
||||
|
||||
[tool.poetry.extras]
|
||||
pgsql = ["psycopg2-binary"]
|
||||
ldap = ["python-ldap"]
|
||||
|
@ -33,4 +33,10 @@ RECIPE_SHOW_NUTRITION=False
|
||||
RECIPE_SHOW_ASSETS=False
|
||||
RECIPE_LANDSCAPE_VIEW=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.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():
|
||||
@ -9,3 +11,37 @@ 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):
|
||||
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