From 2845914d44562aa39956386dedde23e5e10316cf Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Sat, 4 Dec 2021 03:27:23 +0100 Subject: [PATCH] WIP: foundation for a new portal API to partially replace SSOwat --- bin/yunohost-portal-api | 53 ++++++++++++++++++ share/actionsmap-portal.yml | 51 +++++++++++++++++ src/__init__.py | 20 ++++++- src/authenticators/ldap_ynhuser.py | 59 ++++++++++++++++++++ src/portal.py | 89 ++++++++++++++++++++++++++++++ 5 files changed, 271 insertions(+), 1 deletion(-) create mode 100755 bin/yunohost-portal-api create mode 100644 share/actionsmap-portal.yml create mode 100644 src/authenticators/ldap_ynhuser.py create mode 100644 src/portal.py diff --git a/bin/yunohost-portal-api b/bin/yunohost-portal-api new file mode 100755 index 000000000..66751e66f --- /dev/null +++ b/bin/yunohost-portal-api @@ -0,0 +1,53 @@ +#! /usr/bin/python3 +# -*- coding: utf-8 -*- + +import argparse +import yunohost + +# Default server configuration +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 6788 + + +def _parse_api_args(): + """Parse main arguments for the api""" + parser = argparse.ArgumentParser( + add_help=False, + description="Run the YunoHost API to manage your server.", + ) + srv_group = parser.add_argument_group("server configuration") + srv_group.add_argument( + "-h", + "--host", + action="store", + default=DEFAULT_HOST, + help="Host to listen on (default: %s)" % DEFAULT_HOST, + ) + srv_group.add_argument( + "-p", + "--port", + action="store", + default=DEFAULT_PORT, + type=int, + help="Port to listen on (default: %d)" % DEFAULT_PORT, + ) + glob_group = parser.add_argument_group("global arguments") + glob_group.add_argument( + "--debug", + action="store_true", + default=False, + help="Set log level to DEBUG", + ) + glob_group.add_argument( + "--help", + action="help", + help="Show this help message and exit", + ) + + return parser.parse_args() + + +if __name__ == "__main__": + opts = _parse_api_args() + # Run the server + yunohost.portalapi(debug=opts.debug, host=opts.host, port=opts.port) diff --git a/share/actionsmap-portal.yml b/share/actionsmap-portal.yml new file mode 100644 index 000000000..3d07656ae --- /dev/null +++ b/share/actionsmap-portal.yml @@ -0,0 +1,51 @@ +_global: + namespace: yunohost + cookie_name: yunohost.portal + authentication: + api: ldap_ynhuser + cli: null + +portal: + category_help: Portal routes + actions: + + ### portal_me() + me: + action_help: Allow user to fetch their own infos + api: GET /me + + ### portal_apps() + apps: + action_help: Allow users to fetch lit of apps they have access to + api: GET /me/apps + + ### portal_update() + update: + action_help: Allow user to update their infos (display name, mail aliases/forward, password, ...) + api: PUT /me + # FIXME: add args etc + + ### portal_reset_password() + reset_password: + action_help: Allow user to update their infos (display name, mail aliases/forward, ...) + api: PUT /me/reset_password + authentication: + # FIXME: to be implemented ? + api: reset_password_token + # FIXME: add args etc + + ### portal_register() + register: + action_help: Allow user to register using an invite token or ??? + api: POST /me + authentication: + # FIXME: to be implemented ? + api: register_invite_token + # FIXME: add args etc + + ### portal_public() + public: + action_help: Allow anybody to list public apps and other infos regarding the public portal + api: GET /public + authentication: + api: null diff --git a/src/__init__.py b/src/__init__.py index b9dcd93d9..aaeea7751 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -53,6 +53,20 @@ def api(debug, host, port): sys.exit(ret) +def portalapi(debug, host, port): + + # FIXME : is this the logdir we want ? (yolo to work around permission issue) + init_logging(interface="portalapi", debug=debug, logdir="/var/log") + + ret = moulinette.api( + host=host, + port=port, + actionsmap="/usr/share/yunohost/actionsmap-portal.yml", + locales_dir="/usr/share/yunohost/locales/" + ) + sys.exit(ret) + + def check_command_is_valid_before_postinstall(args): allowed_if_not_postinstalled = [ @@ -125,6 +139,10 @@ def init_logging(interface="cli", debug=False, quiet=False, logdir="/var/log/yun "level": "DEBUG" if debug else "INFO", "class": "moulinette.interfaces.api.APIQueueHandler", }, + "portalapi": { + "level": "DEBUG" if debug else "INFO", + "class": "moulinette.interfaces.api.APIQueueHandler", + }, "file": { "class": "logging.FileHandler", "formatter": "precise", @@ -151,7 +169,7 @@ def init_logging(interface="cli", debug=False, quiet=False, logdir="/var/log/yun } # Logging configuration for CLI (or any other interface than api...) # - if interface != "api": + if interface not in ["api", "portalapi"]: configure_logging(logging_configuration) # Logging configuration for API # diff --git a/src/authenticators/ldap_ynhuser.py b/src/authenticators/ldap_ynhuser.py new file mode 100644 index 000000000..50dca3cc9 --- /dev/null +++ b/src/authenticators/ldap_ynhuser.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- + +import logging +import ldap +import ldap.sasl + +from moulinette import m18n +from moulinette.authentication import BaseAuthenticator +from yunohost.utils.error import YunohostError + +logger = logging.getLogger("yunohostportal.authenticators.ldap_ynhuser") + +URI = "ldap://localhost:389" +USERDN = "uid={username},ou=users,dc=yunohost,dc=org" + + +class Authenticator(BaseAuthenticator): + + name = "ldap_ynhuser" + + def _authenticate_credentials(self, credentials=None): + + # FIXME ':' should a legit char in the password ? shall we encode the password as base64 or something idk + if ":" not in credentials or len(credentials.split(":")) != 2: + raise YunohostError("invalid_credentials_format") + + username, password = credentials.split(":") + + def _reconnect(): + con = ldap.ldapobject.ReconnectLDAPObject( + URI, retry_max=2, retry_delay=0.5 + ) + con.simple_bind_s(USERDN.format(username=username), password) + return con + + try: + con = _reconnect() + except ldap.INVALID_CREDENTIALS: + raise YunohostError("invalid_password") + except ldap.SERVER_DOWN: + logger.warning(m18n.n("ldap_server_down")) + + # Check that we are indeed logged in with the expected identity + try: + # whoami_s return dn:..., then delete these 3 characters + who = con.whoami_s()[3:] + except Exception as e: + logger.warning("Error during ldap authentication process: %s", e) + raise + else: + if who != USERDN.format(username=username): + raise YunohostError( + "Not logged with the appropriate identity ?!", + raw_msg=True, + ) + finally: + # Free the connection, we don't really need it to keep it open as the point is only to check authentication... + if con: + con.unbind_s() diff --git a/src/portal.py b/src/portal.py new file mode 100644 index 000000000..4a2b449b2 --- /dev/null +++ b/src/portal.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2021 YUNOHOST.ORG + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses + +""" + +# from moulinette import Moulinette, m18n +from moulinette.utils.log import getActionLogger + +from yunohost.utils.error import YunohostValidationError + +logger = getActionLogger("yunohostportal.user") + + +def me(): + """ + Get user informations + + Keyword argument: + username -- Username to get informations + + """ + + username = None # FIXME : this info should come from the authentication layer + + from yunohost.utils.ldap import _get_ldap_interface + + ldap = _get_ldap_interface() + + user_attrs = ["cn", "mail", "uid", "maildrop", "givenName", "sn", "mailuserquota"] + + filter = "uid=" + username + result = ldap.search("ou=users,dc=yunohost,dc=org", filter, user_attrs) + + if result: + user = result[0] + else: + raise YunohostValidationError("user_unknown", user=username) + + result_dict = { + "username": user["uid"][0], + "fullname": user["cn"][0], + "firstname": user["givenName"][0], + "lastname": user["sn"][0], + "mail": user["mail"][0], + "mail-aliases": [], + "mail-forward": [], + } + + if len(user["mail"]) > 1: + result_dict["mail-aliases"] = user["mail"][1:] + + if len(user["maildrop"]) > 1: + result_dict["mail-forward"] = user["maildrop"][1:] + + if "mailuserquota" in user: + pass + # FIXME + # result_dict["mailbox-quota"] = { + # "limit": userquota if is_limited else m18n.n("unlimit"), + # "use": storage_use, + # } + + # FIXME : should also parse "permission" key in ldap maybe ? + # and list of groups / memberof ? + # (in particular to have e.g. the mail / xmpp / ssh / ... perms) + + return result_dict + + +def apps(username): + return {"foo": "bar"} + # FIXME: should list available apps and corresponding infos ? + # from /etc/ssowat/conf.json ?