Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User management portal API #15

Merged
merged 3 commits into from
Oct 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<node FQDN>/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://<node FQDN>/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.

6 changes: 2 additions & 4 deletions build-images.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand Down
67 changes: 67 additions & 0 deletions imageroot/actions/configure-module/80start_amld
Original file line number Diff line number Diff line change
@@ -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/<LDAP_DOMAIN>" 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'],
gsanchietti marked this conversation as resolved.
Show resolved Hide resolved
'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")
19 changes: 19 additions & 0 deletions imageroot/actions/destroy-module/50cleanup_amld_route
Original file line number Diff line number Diff line change
@@ -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'
},
)
1 change: 1 addition & 0 deletions imageroot/actions/import-module/80start_amld
1 change: 1 addition & 0 deletions imageroot/actions/restore-module/80start_amld
54 changes: 54 additions & 0 deletions imageroot/api-moduled/handlers/change-password/post
Original file line number Diff line number Diff line change
@@ -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)
18 changes: 18 additions & 0 deletions imageroot/api-moduled/handlers/change-password/validate-input.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
45 changes: 45 additions & 0 deletions imageroot/api-moduled/handlers/login/post
Original file line number Diff line number Diff line change
@@ -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)
18 changes: 18 additions & 0 deletions imageroot/api-moduled/handlers/login/validate-input.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Empty file.
Loading