Add LDAP authentication support (v2, onto dev) (#803)

* Add LDAP authentication support

* Add test for LDAP authentication
This commit is contained in:
dvdkon 2021-11-24 18:59:03 +01:00 committed by GitHub
parent 32c864c703
commit 56d9cafb68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 667 additions and 434 deletions

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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