diff --git a/README.md b/README.md index 9534241..590922a 100644 --- a/README.md +++ b/README.md @@ -84,3 +84,37 @@ Get the domain password policy Set the domain password policy api-cli run module/openldap2/set-password-policy --data '{"expiration": {"min_age": 0, "max_age": 7, "enforced": true}, "strength": {"enforced": true, "history_length": 0, "password_min_length": 8, "complexity_check": true}}' + +## User management web portal + +The `openldap` module provides a public web portal where LDAP users can +authenticate and change their passwords. + +The module registers a Traefik path route, with the domain name as suffix. +For instance: + + https:///users-admin/domain.test/ + +The backend endpoint is advertised as `users-admin` service and can be +discovered in the usual ways, as documented in [Service +discovery](https://nethserver.github.io/ns8-core/modules/service_providers/#service-discovery). +For instance: + + api-cli run module/mymodule1/list-service-providers --data '{"service":"users-admin", "filter":{"domain":"dp.nethserver.net","node":"1"}}' + +The event `service-users-admin-changed` is raised when the serivice +becomes available or is changed. + +The backend of the module runs under the `api-moduled.service` Systemd +unit supervision. Refer also to `api-moduled` documentation, provided by +`ns8-core` repository. + +API implementation code is under `imageroot/api-moduled/handlers/`, which +is mapped to an URL like + + https:///users-admin/domain.test/api/ + +The `.json` files define the API input/output syntax validation, using the +JSON schema language. As such they can give an idea of request/response +payload structure. + diff --git a/build-images.sh b/build-images.sh index 95e6897..142ab47 100644 --- a/build-images.sh +++ b/build-images.sh @@ -30,13 +30,11 @@ else touch ui/dist/index.html fi -# Add imageroot directory to the container image buildah add "${container}" imageroot /imageroot buildah add "${container}" ui/dist /ui -# Setup the entrypoint, ask to reserve one TCP port with the label and set a rootless container buildah config --entrypoint=/ \ - --label='org.nethserver.authorizations=ldapproxy@node:accountprovider cluster:accountprovider' \ - --label="org.nethserver.tcp-ports-demand=1" \ + --label='org.nethserver.authorizations=ldapproxy@node:accountprovider cluster:accountprovider traefik@node:routeadm' \ + --label="org.nethserver.tcp-ports-demand=2" \ --label="org.nethserver.rootfull=0" \ --label="org.nethserver.images=${repobase}/openldap-server:${IMAGETAG:-latest}" \ --label 'org.nethserver.flags=core_module account_provider' \ diff --git a/imageroot/actions/configure-module/80start_amld b/imageroot/actions/configure-module/80start_amld new file mode 100755 index 0000000..69fba78 --- /dev/null +++ b/imageroot/actions/configure-module/80start_amld @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2023 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import agent +import agent.tasks +import sys +import os +import string +import secrets +import json + +agent_id = os.environ['AGENT_ID'] +node_id = int(os.environ['NODE_ID']) +domain = os.environ['LDAP_DOMAIN'] +ip_address = os.environ['LDAP_IPADDR'] + +_, amld_port, _ = (os.environ['TCP_PORTS'] + ',,').split(",", 2) + +agent.assert_exp(int(amld_port) > 0) # Ensure TCP port for api-moduled was allocated + +alphabet = string.ascii_letters + string.digits + '+-/,.-_^' +amld_secret = ''.join([secrets.choice(alphabet) for i in range(32)]) + +agent.write_envfile("api-moduled.env", { + "AMLD_JWT_REALM": os.environ["LDAP_DOMAIN"], + "AMLD_JWT_SECRET": amld_secret, + "AMLD_BIND_ADDRESS": ":" + amld_port, + "AMLD_EXPORT_ENV": "NODE_ID LDAP_DOMAIN REDIS_ADDRESS" +}) + +# Configure Traefik to route "/user-admin/" path requests to +# the api-moduled backend service: +response = agent.tasks.run( + agent_id=agent.resolve_agent_id('traefik@node'), + action='set-route', + data={ + 'instance': os.environ['MODULE_ID'] + '-amld', + 'url': 'http://127.0.0.1:' + amld_port, + 'path': '/users-admin/' + os.environ['LDAP_DOMAIN'], + 'http2https': True, + 'strip_prefix': True, + }, +) + +# Add the `users-admin` service discovery information, and advertise this +# new service instance: +rdb = agent.redis_connect(privileged=True) +trx = rdb.pipeline() +trx.delete(agent_id + '/srv/http/users-admin') +trx.hset(agent_id + '/srv/http/users-admin', mapping={ + "port": amld_port, + "url": f"http://{ip_address}:{amld_port}", + "domain": domain, + "node": node_id, +}) +trx.publish(agent_id + '/event/service-users-admin-changed', json.dumps({ + 'domain': domain, + 'node': node_id, + 'key': agent_id + '/srv/http/users-admin', +})) +trx.execute() + +agent.run_helper("systemctl", "-T", "--user", "enable", "--now", "api-moduled.service") diff --git a/imageroot/actions/destroy-module/50cleanup_amld_route b/imageroot/actions/destroy-module/50cleanup_amld_route new file mode 100755 index 0000000..8f03050 --- /dev/null +++ b/imageroot/actions/destroy-module/50cleanup_amld_route @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2023 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import os +import agent +import agent.tasks + +# Remove traefik route +response = agent.tasks.run( + agent_id=agent.resolve_agent_id('traefik@node'), + action='delete-route', + data={ + 'instance': os.environ['MODULE_ID'] + '-amld' + }, +) diff --git a/imageroot/actions/import-module/80start_amld b/imageroot/actions/import-module/80start_amld new file mode 120000 index 0000000..b7be522 --- /dev/null +++ b/imageroot/actions/import-module/80start_amld @@ -0,0 +1 @@ +../configure-module/80start_amld \ No newline at end of file diff --git a/imageroot/actions/restore-module/80start_amld b/imageroot/actions/restore-module/80start_amld new file mode 120000 index 0000000..b7be522 --- /dev/null +++ b/imageroot/actions/restore-module/80start_amld @@ -0,0 +1 @@ +../configure-module/80start_amld \ No newline at end of file diff --git a/imageroot/api-moduled/handlers/change-password/post b/imageroot/api-moduled/handlers/change-password/post new file mode 100755 index 0000000..2046d0d --- /dev/null +++ b/imageroot/api-moduled/handlers/change-password/post @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2023 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import json +import sys +import os +import agent +import subprocess + +from agent.ldapproxy import Ldapproxy +from agent.ldapclient import Ldapclient + +request = json.load(sys.stdin) + +odomain = Ldapproxy().get_domain(os.environ["LDAP_DOMAIN"]) + +oldapcli = Ldapclient.factory(**odomain) + +try: + ouser = oldapcli.get_user_entry(os.environ["JWT_ID"]) +except agent.ldapclient.exceptions.LdapclientEntryNotFound: + json.dump({"status": "failure", "message": "user_not_found"}, fp=sys.stdout) + sys.exit(0) + +oldapcli.ldapconn.unbind() # close LDAP connection FD + +# First attempt: try to change the password with simple bind, using the +# user's credentials +proc_lpasswd = subprocess.run(["podman", "exec", "-i", "openldap", + "ldappasswd", "-e", "ppolicy", "-S", "-W", "-E", "-x", "-D", ouser["dn"]], + input=request["current_password"] + "\n" + request["new_password"] + "\n" + request["new_password"] + "\n", + text=True, capture_output=True) + +if proc_lpasswd.returncode == 49 and "Password expired" in proc_lpasswd.stderr: + print(agent.SD_WARNING + f"User {os.environ['JWT_ID']} is changing their expired password", file=sys.stderr) + # Second attempt: use SASL EXTERNAL authentication to get admin + # privileges and work around the expired password bind error. As + # admins have ACL "write" permission on userPassword field, the + # password quality checks are still applied: + proc_lpasswd = subprocess.run(["podman", "exec", "-i", "openldap", + "ldappasswd", "-e", "ppolicy", "-T", "/dev/stdin", ouser["dn"]], + input=request["new_password"], text=True, capture_output=True) + +# Log the last command output: it might contain troubleshoot information! +print(proc_lpasswd.stdout, file=sys.stderr) + +if proc_lpasswd.returncode == 0: + json.dump({"status": "success", "message": "password_changed"}, fp=sys.stdout) +else: + json.dump({"status": "failure", "message": proc_lpasswd.stdout or f"ldap_error_{proc_lpasswd.returncode}"}, fp=sys.stdout) diff --git a/imageroot/api-moduled/handlers/change-password/validate-input.json b/imageroot/api-moduled/handlers/change-password/validate-input.json new file mode 100644 index 0000000..98ed9dd --- /dev/null +++ b/imageroot/api-moduled/handlers/change-password/validate-input.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "change-password input", + "$id": "http://schema.nethserver.org/ns8-openldap/api-moduled/handlers/change-password/validate-input.json", + "type": "object", + "required": [ + "current_password", + "new_password" + ], + "properties": { + "current_password": { + "type": "string" + }, + "new_password": { + "type": "string" + } + } +} \ No newline at end of file diff --git a/imageroot/api-moduled/handlers/change-password/validate-output.json b/imageroot/api-moduled/handlers/change-password/validate-output.json new file mode 100644 index 0000000..3806803 --- /dev/null +++ b/imageroot/api-moduled/handlers/change-password/validate-output.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "change-password output", + "$id": "http://schema.nethserver.org/ns8-openldap/api-moduled/handlers/change-password/validate-output.json", + "type": "object", + "required": [ + "status", + "message" + ], + "properties": { + "status": { + "enum": [ + "success", + "failure" + ] + }, + "message": { + "type": "string" + } + } +} diff --git a/imageroot/api-moduled/handlers/login/post b/imageroot/api-moduled/handlers/login/post new file mode 100755 index 0000000..65cae9e --- /dev/null +++ b/imageroot/api-moduled/handlers/login/post @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2023 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import json +import sys +import os +import agent +import subprocess + +from agent.ldapproxy import Ldapproxy +from agent.ldapclient import Ldapclient + +request = json.load(sys.stdin) + +odomain = Ldapproxy().get_domain(os.environ["LDAP_DOMAIN"]) + +oldapcli = Ldapclient.factory(**odomain) + +try: + user_dn = oldapcli.get_user_entry(request['username'])["dn"] + ouser = oldapcli.get_user(request['username']) +except agent.ldapclient.exceptions.LdapclientEntryNotFound: + sys.exit(2) # User not found + +proc_whoami = subprocess.run(["podman", "exec", "-i", "openldap", + "ldapwhoami", "-x", "-e", "ppolicy", "-D", user_dn, "-y", "/dev/stdin"], + input=request["password"], text=True, capture_output=True) + +oclaims = { + "uid": ouser["user"], + "groups": ouser["groups"], +} + +if proc_whoami.returncode == 49 and "Password expired" in proc_whoami.stderr: + # Password must be changed immediately: return a token limited to + # password changing: + oclaims["scope"] = ["change-password"] +elif proc_whoami.returncode != 0: + sys.exit(3) # Login failed + +json.dump(oclaims, fp=sys.stdout) diff --git a/imageroot/api-moduled/handlers/login/validate-input.json b/imageroot/api-moduled/handlers/login/validate-input.json new file mode 100644 index 0000000..83027d6 --- /dev/null +++ b/imageroot/api-moduled/handlers/login/validate-input.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "login input", + "$id": "http://schema.nethserver.org/ns8-openldap/api-moduled/handlers/login/validate-input.json", + "type": "object", + "required": [ + "username", + "password" + ], + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + } +} diff --git a/imageroot/api-moduled/public/.gitignore b/imageroot/api-moduled/public/.gitignore new file mode 100644 index 0000000..e69de29