From 04e33a8d24f5506cb01bd81c0b94030cc96fa573 Mon Sep 17 00:00:00 2001 From: Luc Didry Date: Thu, 28 Nov 2024 15:08:02 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=F0=9F=9B=82=20=E2=80=94=20Allow=20to?= =?UTF-8?q?=20use=20a=20LDAP=20server=20for=20authentication=20(fix=20#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitlab-ci.yml | 6 +++++- CHANGELOG.md | 1 + Makefile | 2 +- argos/config-example.yaml | 17 ++++++++++++++++ argos/schemas/config.py | 10 ++++++++++ argos/server/alerting.py | 8 ++++---- argos/server/main.py | 16 +++++++++++++-- argos/server/routes/api.py | 2 +- argos/server/routes/dependencies.py | 25 +++++++++++++++++++++++ argos/server/routes/views.py | 30 ++++++++++++++++++++-------- docs/cli.md | 6 +++++- docs/installation/getting-started.md | 20 +++++++++++++++++++ pyproject.toml | 5 ++++- 13 files changed, 129 insertions(+), 19 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 30c8c7a..6f3ef20 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,3 +1,4 @@ +--- image: python:3.11 stages: @@ -18,6 +19,9 @@ default: install: stage: install + before_script: + - apt-get update + - apt-get install -y build-essential libldap-dev libsasl2-dev script: - make venv - make develop @@ -64,7 +68,7 @@ release_job: - if: $CI_COMMIT_TAG script: - sed -n '/^## '$CI_COMMIT_TAG'/,/^#/p' CHANGELOG.md | sed -e '/^\(#\|$\|Date\)/d' > release.md - release: # See https://docs.gitlab.com/ee/ci/yaml/#release for available properties + release: # See https://docs.gitlab.com/ee/ci/yaml/#release for available properties tag_name: '$CI_COMMIT_TAG' description: './release.md' assets: diff --git a/CHANGELOG.md b/CHANGELOG.md index 75fdaf6..42f7dc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ You need to use `M`, `month` or `months` - ✨ - Allow to choose a frequency smaller than a minute - ✨🛂 — Allow partial or total anonymous access to web interface (#63) +- ✨🛂 — Allow to use a LDAP server for authentication (#64) ## 0.5.0 diff --git a/Makefile b/Makefile index 9d6bec1..86e1737 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ NC=\033[0m # No Color venv: ## Create the venv python3 -m venv venv develop: venv ## Install the dev dependencies - venv/bin/pip install -e ".[dev,docs]" + venv/bin/pip install -e ".[dev,docs,ldap]" docs: cog ## Build the docs venv/bin/sphinx-build docs public if [ ! -e "public/mermaid.min.js" ]; then curl -sL $$(grep mermaid.min.js public/search.html | cut -f 2 -d '"') --output public/mermaid.min.js; fi diff --git a/argos/config-example.yaml b/argos/config-example.yaml index 3e713ba..b20c48f 100644 --- a/argos/config-example.yaml +++ b/argos/config-example.yaml @@ -36,6 +36,23 @@ general: # If not present, all pages needs authentication # unauthenticated_access: "all" + # LDAP authentication + # Instead of relying on Argos’ users, use a LDAP server to authenticate users. + # If not present, Argos’ native user system is used. + # ldap: + # # Server URI + # uri: "ldaps://ldap.example.org" + # # Search base DN + # user_tree: "ou=users,dc=example,dc=org" + # # Search bind DN + # bind_dn: "uid=ldap_user,ou=users,dc=example,dc=org" + # # Search bind password + # bind_pwd: "secr3t" + # # User attribute (uid, mail, sAMAccountName, etc.) + # user_attr: "uid" + # # User filter (to exclude some users, etc.) + # user_filter: "(!(uid=ldap_user))" + # Default delay for checks. # Can be superseeded in domain configuration. # For ex., to run checks every 5 minutes: diff --git a/argos/schemas/config.py b/argos/schemas/config.py index 3bd5c62..13119b0 100644 --- a/argos/schemas/config.py +++ b/argos/schemas/config.py @@ -183,6 +183,15 @@ class DbSettings(BaseModel): max_overflow: int = 20 +class LdapSettings(BaseModel): + uri: str + user_tree: str + bind_dn: str | None = None + bind_pwd: str | None = None + user_attr: str + user_filter: str | None = None + + class General(BaseModel): """Frequency for the checks and alerts""" @@ -192,6 +201,7 @@ class General(BaseModel): session_duration: int = 10080 # 7 days remember_me_duration: int | None = None unauthenticated_access: Unauthenticated | None = None + ldap: LdapSettings | None = None frequency: float recheck_delay: float | None = None root_path: str = "" diff --git a/argos/server/alerting.py b/argos/server/alerting.py index 60384aa..4c82a9b 100644 --- a/argos/server/alerting.py +++ b/argos/server/alerting.py @@ -25,7 +25,7 @@ def get_icon_from_severity(severity: str) -> str: return icon -def handle_alert(config: Config, result, task, severity, old_severity, request): +def handle_alert(config: Config, result, task, severity, old_severity, request): # pylint: disable-msg=too-many-positional-arguments """Dispatch alert through configured alert channels""" if "local" in getattr(config.general.alerts, severity): @@ -64,7 +64,7 @@ def handle_alert(config: Config, result, task, severity, old_severity, request): ) -def notify_with_apprise( +def notify_with_apprise( # pylint: disable-msg=too-many-positional-arguments result, task, severity: str, old_severity: str, group: List[str], request ) -> None: logger.debug("Will send apprise notification") @@ -90,7 +90,7 @@ See results of task on {request.url_for('get_task_results_view', task_id=task.id apobj.notify(title=title, body=msg) -def notify_by_mail( +def notify_by_mail( # pylint: disable-msg=too-many-positional-arguments result, task, severity: str, old_severity: str, config: Mail, request ) -> None: logger.debug("Will send mail notification") @@ -137,7 +137,7 @@ See results of task on {request.url_for('get_task_results_view', task_id=task.id smtp.send_message(mail, to_addrs=address) -def notify_with_gotify( +def notify_with_gotify( # pylint: disable-msg=too-many-positional-arguments result, task, severity: str, old_severity: str, config: List[GotifyUrl], request ) -> None: logger.debug("Will send gotify notification") diff --git a/argos/server/main.py b/argos/server/main.py index fc3957b..0543182 100644 --- a/argos/server/main.py +++ b/argos/server/main.py @@ -36,13 +36,25 @@ def get_application() -> FastAPI: appli.add_exception_handler(NotAuthenticatedException, auth_exception_handler) appli.state.manager = create_manager(config.general.cookie_secret) + if config.general.ldap is not None: + import ldap + + l = ldap.initialize(config.general.ldap.uri) + l.simple_bind_s(config.general.ldap.bind_dn, config.general.ldap.bind_pwd) + appli.state.ldap = l + @appli.state.manager.user_loader() - async def query_user(user: str) -> None | models.User: + async def query_user(user: str) -> None | str | models.User: """ - Get a user from the db + Get a user from the db or LDAP :param user: name of the user :return: None or the user object """ + if appli.state.config.general.ldap is not None: + from argos.server.routes.dependencies import find_ldap_user + + return await find_ldap_user(appli.state.config, appli.state.ldap, user) + return await queries.get_user(appli.state.db, user) appli.include_router(routes.api, prefix="/api") diff --git a/argos/server/routes/api.py b/argos/server/routes/api.py index ec132ca..cc96132 100644 --- a/argos/server/routes/api.py +++ b/argos/server/routes/api.py @@ -30,7 +30,7 @@ async def read_tasks( @route.post("/results", status_code=201, dependencies=[Depends(verify_token)]) -async def create_results( +async def create_results( # pylint: disable-msg=too-many-positional-arguments request: Request, results: List[AgentResult], background_tasks: BackgroundTasks, diff --git a/argos/server/routes/dependencies.py b/argos/server/routes/dependencies.py index 3f3b6c5..e61b77a 100644 --- a/argos/server/routes/dependencies.py +++ b/argos/server/routes/dependencies.py @@ -31,3 +31,28 @@ async def verify_token( if token.credentials not in request.app.state.config.service.secrets: raise HTTPException(status_code=401, detail="Unauthorized") return token + + +async def find_ldap_user(config, ldap, user: str) -> str | None: + """Do a LDAP search for user and return its dn""" + import ldap.filter as ldap_filter + from ldapurl import LDAP_SCOPE_SUBTREE + + result = ldap.search_s( + config.general.ldap.user_tree, + LDAP_SCOPE_SUBTREE, + filterstr=ldap_filter.filter_format( + f"(&(%s=%s){config.general.ldap.user_filter})", + [ + config.general.ldap.user_attr, + user, + ], + ), + attrlist=[config.general.ldap.user_attr], + ) + + # If there is a result, there should, logically, be only one entry + if len(result) > 0: + return result[0][0] + + return None diff --git a/argos/server/routes/views.py b/argos/server/routes/views.py index d320e1d..ae2f51c 100644 --- a/argos/server/routes/views.py +++ b/argos/server/routes/views.py @@ -80,20 +80,34 @@ async def post_login( ) username = data.username - user = await queries.get_user(db, username) + invalid_credentials = templates.TemplateResponse( "login.html", {"request": request, "msg": "Sorry, invalid username or bad password."}, ) - if user is None: - return invalid_credentials - pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - if not pwd_context.verify(data.password, user.password): - return invalid_credentials + if config.general.ldap is not None: + from ldap import INVALID_CREDENTIALS # pylint: disable-msg=no-name-in-module + from argos.server.routes.dependencies import find_ldap_user - user.last_login_at = datetime.now() - db.commit() + ldap_dn = await find_ldap_user(config, request.app.state.ldap, username) + if ldap_dn is None: + return invalid_credentials + try: + request.app.state.ldap.simple_bind_s(ldap_dn, data.password) + except INVALID_CREDENTIALS: + return invalid_credentials + else: + user = await queries.get_user(db, username) + if user is None: + return invalid_credentials + + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + if not pwd_context.verify(data.password, user.password): + return invalid_credentials + + user.last_login_at = datetime.now() + db.commit() manager = request.app.state.manager session_duration = config.general.session_duration diff --git a/docs/cli.md b/docs/cli.md index 933aba1..a9aac5e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -280,7 +280,11 @@ You can choose to protect Argos’ web interface with a user system, in which ca See [`unauthenticated_access` in the configuration file](configuration.md) to allow partial or total unauthenticated access to Argos. -You can manage users only through CLI. +See [`ldap` in the configuration file](configuration.md) to authenticate users against a LDAP server instead of Argos’ database. + +You can manage Argos’ users only through CLI. + +NB: you can’t manage the LDAP users with Argos.