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

Add user portal UI and API #24

Merged
merged 5 commits into from
Nov 2, 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ns8-user-manager-*.tar.gz

33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,39 @@ Create a new user and assign it to the `developers` group
}
EOF

## User management web portal

The `samba` module provides a public web portal where AD 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.

## File server

If Samba binds to the **internal VPN interface** (see `ipaddress`
Expand Down
8 changes: 7 additions & 1 deletion build-images.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ images=()
repobase="${REPOBASE:-ghcr.io/nethserver}"
reponame="ubuntu-samba"
ubuntu_tag=23.04
user_manager_version=v0.2.1

container="ubuntu-working-container"
# Prepare a local Ubuntu-based samba image
Expand Down Expand Up @@ -69,6 +70,9 @@ if ! buildah containers --format "{{.ContainerName}}" | grep -q nodebuilder-samb
buildah from --name nodebuilder-samba -v "${PWD}:/usr/src:Z" docker.io/library/node:lts
fi

echo "Downloading user manager ${user_manager_version} UI..."
curl -f -O -L https://github.com/NethServer/ns8-user-manager/releases/download/${user_manager_version}/ns8-user-manager-${user_manager_version}.tar.gz

echo "Build static UI files with node..."
buildah run \
--workingdir=/usr/src/ui \
Expand All @@ -77,10 +81,12 @@ buildah run \
sh -c "yarn install && yarn build"

buildah add "${container}" imageroot /imageroot
buildah add "${container}" ns8-user-manager-${user_manager_version}.tar.gz /imageroot/api-moduled/public
buildah add "${container}" ui/dist /ui
buildah config \
--label "org.nethserver.images=ghcr.io/nethserver/samba-dc:${IMAGETAG:-latest}" \
--label 'org.nethserver.authorizations=node:fwadm ldapproxy@node:accountprovider cluster:accountprovider' \
--label 'org.nethserver.authorizations=node:fwadm ldapproxy@node:accountprovider cluster:accountprovider traefik@node:routeadm' \
--label="org.nethserver.tcp-ports-demand=1" \
--label 'org.nethserver.flags=core_module account_provider' \
--entrypoint=/ "${container}"
buildah commit "${container}" "${repobase}/${reponame}"
Expand Down
69 changes: 69 additions & 0 deletions imageroot/actions/configure-module/80start_amld
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/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['REALM'].lower()
amld_port = os.environ['TCP_PORT']

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": domain,
"AMLD_JWT_SECRET": amld_secret,
"AMLD_BIND_ADDRESS": ":" + amld_port,
"AMLD_EXPORT_ENV": "NODE_ID REALM REDIS_ADDRESS"
})

# Configure Traefik to route "/user-admin/<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/' + domain,
'http2https': True,
'strip_prefix': True,
},
)

# Find the node VPN IP address for users-admin advertising:
ro_rdb = agent.redis_connect()
ip_address = ro_rdb.hget(f'node/{node_id}/vpn', 'ip_address')

# 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")
1 change: 1 addition & 0 deletions imageroot/actions/import-module/80start_amld
1 change: 1 addition & 0 deletions imageroot/actions/restore-module/80start_amld
60 changes: 60 additions & 0 deletions imageroot/api-moduled/handlers/change-password/post
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/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
import hashlib

from agent.ldapproxy import Ldapproxy
from agent.ldapclient import Ldapclient

request = json.load(sys.stdin)

odomain = Ldapproxy().get_domain(os.environ["REALM"].lower())

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

# Unique and impredictable cache file name:
cache_file = "/tmp/login_" + hashlib.sha256(("change-password-" + os.environ["JWT_ID"] + request['current_password']).encode()).hexdigest()

proc_kpasswd = subprocess.run(["podman", "exec", "-e", "KRB5CCNAME=" + cache_file, "-i", "samba-dc", "kpasswd", os.environ["JWT_ID"]],
input=request["current_password"] + "\n" + request["new_password"] + "\n" + request["new_password"] + "\n",
text=True, capture_output=True)

if proc_kpasswd.returncode == 0:
json.dump({"status": "success", "message": "password_changed"}, fp=sys.stdout)
else:
# Combined output
kmessage = proc_kpasswd.stdout + proc_kpasswd.stderr

if 'Preauthentication failed' in kmessage:
emessage = "error_invalid_credentials"
elif 'complexity requirements' in kmessage:
emessage = "error_password_complexity"
elif 'too short' in kmessage:
emessage = "error_password_length"
elif 'already in password history' in kmessage:
emessage = "error_password_history"
elif 'minimum password age' in kmessage:
emessage = "error_password_minimum_age"
else:
emessage = "error_unknown"
# Log the combined output: it might contain troubleshoot information!
print("kpasswd:", kmessage, file=sys.stderr)

json.dump({"status": "failure", "message": emessage}, 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-samba/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-samba/api-moduled/handlers/change-password/validate-output.json",
"type": "object",
"required": [
"status",
"message"
],
"properties": {
"status": {
"enum": [
"success",
"failure"
]
},
"message": {
"type": "string"
}
}
}
53 changes: 53 additions & 0 deletions imageroot/api-moduled/handlers/login/post
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/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
import hashlib

from agent.ldapproxy import Ldapproxy
from agent.ldapclient import Ldapclient

request = json.load(sys.stdin)

odomain = Ldapproxy().get_domain(os.environ["REALM"].lower())

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

# Unique and impredictable cache file name:
cache_file = "/tmp/login_" + hashlib.sha256(("login-" + request['username'] + request['password']).encode()).hexdigest()

proc_kinit = subprocess.run(["podman", "exec", "-e", "KRB5CCNAME=" + cache_file, "-i", "samba-dc",
"kinit", request["username"]],
input=request["password"], text=True, capture_output=True)

oclaims = {
"uid": ouser["user"],
"groups": list(ogroup["group"] for ogroup in ouser["groups"]),
}

if proc_kinit.returncode != 0:
if "Password expired. You must change it now." in proc_kinit.stdout:
# Password must be changed immediately: return a token limited to
# password changing:
oclaims["scope"] = ["change-password"]
else:
sys.exit(3) # Login failed

# Clean up the cache file after a successful login:
subprocess.run(["podman", "exec", "samba-dc", "rm", "-f", cache_file], text=True, capture_output=True)

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-samba/api-moduled/handlers/login/validate-input.json",
"type": "object",
"required": [
"username",
"password"
],
"properties": {
"username": {
"type": "string"
},
"password": {
"type": "string"
}
}
}
Empty file.
17 changes: 14 additions & 3 deletions renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
"test-module.sh"
],
"matchStrings": [
"ghcr\\.io\/marketsquare\/robotframework-browser/rfbrowser-stable:(?currentValue[^\\s]+)"
"ghcr\\.io/marketsquare/robotframework-browser/rfbrowser-stable:(?<currentValue>[^\\s]+)"
],
"datasourceTemplate": "docker"
"depNameTemplate": "MarketSquare/robotframework-browser",
"datasourceTemplate": "github-releases"
},
{
"fileMatch": [
Expand All @@ -31,9 +32,19 @@
"build-images.sh"
],
"matchStrings": [
"docker\\.io\/library\/node:(?<currentValue>[^\\s]+)"
"\\bdocker\\.io/(?<depName>.+):(?<currentValue>[-0-9\\.a-z]+)"
],
"datasourceTemplate": "docker"
},
{
"fileMatch": [
"build-images.sh"
],
"matchStrings": [
"user_manager_version=(?<currentValue>.*?)\\n"
],
"depNameTemplate": "NethServer/ns8-user-manager",
"datasourceTemplate": "github-releases"
}
],
"packageRules": [
Expand Down
Loading
Loading