Skip to content

Commit

Permalink
Merge pull request #15 from NethServer/user-portal
Browse files Browse the repository at this point in the history
User management portal API
  • Loading branch information
DavidePrincipi authored Oct 17, 2023
2 parents b3d5574 + 7d0d52e commit c5726f5
Show file tree
Hide file tree
Showing 12 changed files with 280 additions and 4 deletions.
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'],
'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.

0 comments on commit c5726f5

Please sign in to comment.