From e9a6c7108d87cc698fa62684b57be1f138056f43 Mon Sep 17 00:00:00 2001 From: darta Date: Sun, 17 Apr 2022 00:12:46 +0200 Subject: [PATCH] feat(api): users and groups actions --- admin/docker/requirements.pip3 | 5 +- admin/src/admin/__init__.py | 2 +- admin/src/admin/auth/tokens.py | 96 +++ admin/src/admin/lib/admin.py | 8 + admin/src/admin/lib/api_exceptions.py | 143 ++++ admin/src/admin/lib/load_config.py | 40 +- admin/src/admin/schemas/group.yml | 11 + admin/src/admin/schemas/user.yml | 29 + admin/src/admin/schemas/user_update.yml | 30 + admin/src/admin/views/ApiViews.py | 775 ++++++------------ .../views/{InternalViews.py => WpViews.py} | 16 +- admin/src/admin/views/decorators.py | 67 +- admin/src/start.py | 10 +- 13 files changed, 667 insertions(+), 565 deletions(-) create mode 100644 admin/src/admin/auth/tokens.py create mode 100644 admin/src/admin/lib/api_exceptions.py create mode 100644 admin/src/admin/schemas/group.yml create mode 100644 admin/src/admin/schemas/user.yml create mode 100644 admin/src/admin/schemas/user_update.yml rename admin/src/admin/views/{InternalViews.py => WpViews.py} (95%) diff --git a/admin/docker/requirements.pip3 b/admin/docker/requirements.pip3 index 5d387f6..5d6ee2c 100644 --- a/admin/docker/requirements.pip3 +++ b/admin/docker/requirements.pip3 @@ -3,7 +3,6 @@ Flask-Login==0.5.0 eventlet==0.33.0 Flask-SocketIO==5.1.0 bcrypt==3.2.0 - diceware==0.9.6 mysql-connector-python==8.0.25 psycopg2==2.8.6 @@ -13,5 +12,5 @@ urllib3==1.26.6 schema==0.7.5 Werkzeug~=2.0.0 python-jose==3.3.0 -# Unused yet -#flask-oidc==1.4.0 +Cerberus==1.3.4 +PyYAML==6.0 diff --git a/admin/src/admin/__init__.py b/admin/src/admin/__init__.py index a5c52cf..2fc10bd 100644 --- a/admin/src/admin/__init__.py +++ b/admin/src/admin/__init__.py @@ -108,4 +108,4 @@ def send_custom(path): """ Import all views """ -from .views import ApiViews, InternalViews, LoginViews, WebViews +from .views import ApiViews, AppViews, LoginViews, WebViews, WpViews diff --git a/admin/src/admin/auth/tokens.py b/admin/src/admin/auth/tokens.py new file mode 100644 index 0000000..3f55578 --- /dev/null +++ b/admin/src/admin/auth/tokens.py @@ -0,0 +1,96 @@ +# Copyright 2017 the Isard-vdi project authors: +# Josep Maria ViƱolas Auquer +# Alberto Larraz Dalmases +# License: AGPLv3 + +import json +import logging as log +import os +import traceback +from functools import wraps + +from flask import request +from jose import jwt + +from admin import app + +from ..lib.api_exceptions import Error + + +def get_header_jwt_payload(): + return get_token_payload(get_token_auth_header()) + + +def get_token_header(header): + """Obtains the Access Token from the a Header""" + auth = request.headers.get(header, None) + if not auth: + raise Error( + "unauthorized", + "Authorization header is expected", + traceback.format_stack(), + ) + + parts = auth.split() + if parts[0].lower() != "bearer": + raise Error( + "unauthorized", + "Authorization header must start with Bearer", + traceback.format_stack(), + ) + + elif len(parts) == 1: + raise Error("bad_request", "Token not found") + elif len(parts) > 2: + raise Error( + "unauthorized", + "Authorization header must be Bearer token", + traceback.format_stack(), + ) + + return parts[1] # Token + + +def get_token_auth_header(): + return get_token_header("Authorization") + + +def get_token_payload(token): + try: + claims = jwt.get_unverified_claims(token) + secret = app.config["API_SECRET"] + + except: + log.warning("JWT token with invalid parameters. Can not parse it.") + raise Error( + "unauthorized", + "Unable to parse authentication parameters token.", + traceback.format_stack(), + ) + + try: + payload = jwt.decode( + token, + secret, + algorithms=["HS256"], + options=dict(verify_aud=False, verify_sub=False, verify_exp=True), + ) + except jwt.ExpiredSignatureError: + log.info("Token expired") + raise Error("unauthorized", "Token is expired", traceback.format_stack()) + + except jwt.JWTClaimsError: + raise Error( + "unauthorized", + "Incorrect claims, please check the audience and issuer", + traceback.format_stack(), + ) + except Exception: + raise Error( + "unauthorized", + "Unable to parse authentication token.", + traceback.format_stack(), + ) + if payload.get("data", False): + return payload["data"] + return payload diff --git a/admin/src/admin/lib/admin.py b/admin/src/admin/lib/admin.py index 641c2cd..1286cfb 100644 --- a/admin/src/admin/lib/admin.py +++ b/admin/src/admin/lib/admin.py @@ -30,6 +30,7 @@ options.num = 3 import secrets +from .api_exceptions import Error from .events import Events from .exceptions import UserExists, UserNotFound from .helpers import ( @@ -466,6 +467,10 @@ class Admin: def _get_roles(self): return filter_roles_listofdicts(self.keycloak.get_roles()) + def get_group_by_name(self, group_name): + group = [g for g in self.internal["groups"] if g["name"] == group_name] + return group[0] if len(group) else False + def get_keycloak_groups(self): log.warning("Loading keycloak groups...") return self.keycloak.get_groups() @@ -1812,6 +1817,7 @@ class Admin: log.error(traceback.format_exc()) self.resync_data() + return uid def add_group(self, g): # TODO: Check if exists @@ -1830,6 +1836,7 @@ class Admin: self.moodle.add_system_cohort(new_path, description=g["description"]) self.nextcloud.add_group(new_path) self.resync_data() + return new_path def delete_group_by_id(self, group_id): ev = Events("Deleting group", "Deleting from keycloak") @@ -1843,6 +1850,7 @@ class Admin: + str(group_id) + " as it does not exist!" ) + raise Error("not_found", "Group " + group_id + " not found.") # {'id': '966ad67c-499a-4f56-bd1d-283691cde0e7', 'name': 'asdgfewfwe', 'path': '/asdgfewfwe', 'attributes': {}, 'realmRoles': [], 'clientRoles': {}, 'subGroups': [], 'access': {'view': True, 'manage': True, 'manageMembership': True}} diff --git a/admin/src/admin/lib/api_exceptions.py b/admin/src/admin/lib/api_exceptions.py new file mode 100644 index 0000000..3873f22 --- /dev/null +++ b/admin/src/admin/lib/api_exceptions.py @@ -0,0 +1,143 @@ +import inspect +import json +import logging as log +import os +import traceback + +from flask import jsonify, request + +from admin import app + +content_type = {"Content-Type": "application/json"} +ex = { + "bad_request": { + "error": { + "error": "bad_request", + "msg": "Bad request", + }, + "status_code": 400, + }, + "unauthorized": { + "error": { + "error": "unauthorized", + "msg": "Unauthorized", + }, + "status_code": 401, + }, + "forbidden": { + "error": { + "error": "forbidden", + "msg": "Forbidden", + }, + "status_code": 403, + }, + "not_found": { + "error": { + "error": "not_found", + "msg": "Not found", + }, + "status_code": 404, + }, + "conflict": { + "error": { + "error": "conflict", + "msg": "Conflict", + }, + "status_code": 409, + }, + "internal_server": { + "error": { + "error": "internal_server", + "msg": "Internal server error", + }, + "status_code": 500, + }, + "gateway_timeout": { + "error": { + "error": "gateway_timeout", + "msg": "Gateway timeout", + }, + "status_code": 504, + }, + "precondition_required": { + "error": { + "error": "precondition_required", + "msg": "Precondition required", + }, + "status_code": 428, + }, + "insufficient_storage": { + "error": { + "error": "insufficient_storage", + "msg": "Insufficient storage", + }, + "status_code": 507, + }, +} + + +class Error(Exception): + def __init__(self, error="bad_request", description="", debug="", data=None): + self.error = ex[error]["error"].copy() + self.error["function"] = ( + inspect.stack()[1][1].split(os.sep)[-1] + + ":" + + str(inspect.stack()[1][2]) + + ":" + + inspect.stack()[1][3] + ) + self.error["function_call"] = ( + inspect.stack()[2][1].split(os.sep)[-1] + + ":" + + str(inspect.stack()[2][2]) + + ":" + + inspect.stack()[2][3] + ) + self.error["description"] = str(description) + self.error["debug"] = "{}\n\r{}{}".format( + "----------- DEBUG START -------------", + debug, + "----------- DEBUG STOP -------------", + ) + self.error["request"] = ( + "{}\n{}\r\n{}\r\n\r\n{}{}".format( + "----------- REQUEST START -----------", + request.method + " " + request.url, + "\r\n".join("{}: {}".format(k, v) for k, v in request.headers.items()), + request.body if hasattr(request, "body") else "", + "----------- REQUEST STOP -----------", + ) + if request + else "" + ) + self.error["data"] = ( + "{}\n{}\n{}".format( + "----------- DATA START -----------", + json.dumps(data, indent=2), + "----------- DATA STOP -----------", + ) + if data + else "" + ) + self.status_code = ex[error]["status_code"] + self.content_type = content_type + log.debug( + "%s - %s - [%s -> %s]\r\n%s\r\n%s\r\n%s" + % ( + error, + str(description), + self.error["function_call"], + self.error["function"], + self.error["debug"], + self.error["request"], + self.error["data"], + ) + ) + + +@app.errorhandler(Error) +def handle_user_error(ex): + response = jsonify(ex.error) + response.status_code = ex.status_code + response.headers = {"content-type": content_type} + return response diff --git a/admin/src/admin/lib/load_config.py b/admin/src/admin/lib/load_config.py index cb66a37..ba193d8 100644 --- a/admin/src/admin/lib/load_config.py +++ b/admin/src/admin/lib/load_config.py @@ -6,9 +6,45 @@ import os import sys import traceback +import yaml +from cerberus import Validator, rules_set_registry, schema_registry + from admin import app +class AdminValidator(Validator): + None + # def _normalize_default_setter_genid(self, document): + # return _parse_string(document["name"]) + + # def _normalize_default_setter_genidlower(self, document): + # return _parse_string(document["name"]).lower() + + # def _normalize_default_setter_gengroupid(self, document): + # return _parse_string( + # document["parent_category"] + "-" + document["uid"] + # ).lower() + + +def load_validators(purge_unknown=True): + validators = {} + schema_path = os.path.join(app.root_path, "schemas") + for schema_filename in os.listdir(schema_path): + try: + with open(os.path.join(schema_path, schema_filename)) as file: + schema_yml = file.read() + schema = yaml.load(schema_yml, Loader=yaml.FullLoader) + validators[schema_filename.split(".")[0]] = AdminValidator( + schema, purge_unknown=purge_unknown + ) + except IsADirectoryError: + None + return validators + + +app.validators = load_validators() + + class loadConfig: def __init__(self, app=None): try: @@ -34,9 +70,7 @@ class loadConfig: app.config.setdefault( "VERIFY", True if os.environ["VERIFY"] == "true" else False ) - app.config.setdefault( - "API_SECRET", os.environ.get("API_SECRET") - ) + app.config.setdefault("API_SECRET", os.environ.get("API_SECRET")) except Exception as e: log.error(traceback.format_exc()) raise diff --git a/admin/src/admin/schemas/group.yml b/admin/src/admin/schemas/group.yml new file mode 100644 index 0000000..fb1e631 --- /dev/null +++ b/admin/src/admin/schemas/group.yml @@ -0,0 +1,11 @@ +name: + required: true + type: string +description: + required: false + type: string + default: "Api created" +parent: + required: false + type: string + default: "" \ No newline at end of file diff --git a/admin/src/admin/schemas/user.yml b/admin/src/admin/schemas/user.yml new file mode 100644 index 0000000..b94083d --- /dev/null +++ b/admin/src/admin/schemas/user.yml @@ -0,0 +1,29 @@ +username: + required: true + type: string +first: + required: true + type: string +last: + required: true + type: string +email: + required: true + type: string +password: + required: true + type: string +quota: + required: true + type: string +enabled: + required: true + type: boolean +role: + required: true + type: string + empty: false +groups: + required: true + type: list + diff --git a/admin/src/admin/schemas/user_update.yml b/admin/src/admin/schemas/user_update.yml new file mode 100644 index 0000000..6bc8c56 --- /dev/null +++ b/admin/src/admin/schemas/user_update.yml @@ -0,0 +1,30 @@ +first: + required: false + type: string +last: + required: false + type: string +email: + required: false + type: string +password: + required: false + type: string +password_temporary: + required: false + type: boolean + default: true +quota: + required: false + type: string +enabled: + required: false + type: boolean +role: + required: false + type: string + empty: false +groups: + required: false + type: list + diff --git a/admin/src/admin/views/ApiViews.py b/admin/src/admin/views/ApiViews.py index 1fdc6dd..91d152e 100644 --- a/admin/src/admin/views/ApiViews.py +++ b/admin/src/admin/views/ApiViews.py @@ -1,545 +1,298 @@ #!flask/bin/python # coding=utf-8 -import concurrent.futures import json import logging as log import os -import re +import socket import sys - -# import Queue -import threading import time import traceback -from uuid import uuid4 -from flask import Response, jsonify, redirect, render_template, request, url_for -from flask_login import current_user, login_required +from flask import request from admin import app -from ..lib.helpers import system_group -from .decorators import is_admin - -threads = {"external": None} -# q = Queue.Queue() - -from keycloak.exceptions import KeycloakGetError - -from ..lib.dashboard import Dashboard -from ..lib.exceptions import UserExists, UserNotFound - -dashboard = Dashboard() +from ..lib.api_exceptions import Error +from .decorators import has_token -@app.route("/sysadmin/api/resync") -@app.route("/api/resync") -@login_required -def resync(): - return ( - json.dumps(app.admin.resync_data()), - 200, - {"Content-Type": "application/json"}, - ) +## LISTS +@app.route("/ddapi/users", methods=["GET"]) +@has_token +def ddapi_users(): + if request.method == "GET": + sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) + users = [] + for user in sorted_users: + users.append(user_parser(user)) + return json.dumps(users), 200, {"Content-Type": "application/json"} -@app.route("/api/users", methods=["GET", "PUT"]) -@app.route("/api/users/", methods=["POST", "PUT", "GET", "DELETE"]) -@login_required -def users(provider=False): - if request.method == "DELETE": - if current_user.role != "admin": - return json.dumps({}), 301, {"Content-Type": "application/json"} - if provider == "keycloak": - return ( - json.dumps(app.admin.delete_keycloak_users()), - 200, - {"Content-Type": "application/json"}, - ) - if provider == "nextcloud": - return ( - json.dumps(app.admin.delete_nextcloud_users()), - 200, - {"Content-Type": "application/json"}, - ) - if provider == "moodle": - return ( - json.dumps(app.admin.delete_moodle_users()), - 200, - {"Content-Type": "application/json"}, - ) +@app.route("/ddapi/users/filter", methods=["POST"]) +@has_token +def ddapi_users_search(): if request.method == "POST": - if current_user.role != "admin": - return json.dumps({}), 301, {"Content-Type": "application/json"} - if provider == "moodle": - return ( - json.dumps(app.admin.sync_to_moodle()), - 200, - {"Content-Type": "application/json"}, - ) - if provider == "nextcloud": - return ( - json.dumps(app.admin.sync_to_nextcloud()), - 200, - {"Content-Type": "application/json"}, - ) - if request.method == "PUT" and not provider: - if current_user.role != "admin": - return json.dumps({}), 301, {"Content-Type": "application/json"} + data = request.get_json(force=True) + if not data.get("text"): + raise Error("bad_request", "Incorrect data requested.") + users = app.admin.get_mix_users() + result = [user_parser(user) for user in filter_users(users, data["text"])] + sorted_result = sorted(result, key=lambda k: k["id"]) + return json.dumps(sorted_result), 200, {"Content-Type": "application/json"} - if "external" in threads.keys(): - if threads["external"] is not None and threads["external"].is_alive(): - return ( - json.dumps( - {"msg": "Precondition failed: already working with users"} - ), - 412, - {"Content-Type": "application/json"}, - ) - else: - threads["external"] = None - try: - threads["external"] = threading.Thread( - target=app.admin.update_users_from_keycloak, args=() - ) - threads["external"].start() - return json.dumps({}), 200, {"Content-Type": "application/json"} - except: - log.error(traceback.format_exc()) - return ( - json.dumps({"msg": "Add user error."}), - 500, - {"Content-Type": "application/json"}, - ) - # return json.dumps(app.admin.update_users_from_keycloak()), 200, {'Content-Type': 'application/json'} +@app.route("/ddapi/groups", methods=["GET"]) +@has_token +def ddapi_groups(): + if request.method == "GET": + sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: k["name"]) + groups = [] + for group in sorted_groups: + groups.append(group_parser(group)) + return json.dumps(groups), 200, {"Content-Type": "application/json"} - users = app.admin.get_mix_users() - if current_user.role != "admin": - for user in users: - user["keycloak_groups"] = [ - g for g in user["keycloak_groups"] if not system_group(g) + +@app.route("/ddapi/group/users", methods=["POST"]) +@has_token +def ddapi_group_users(): + if request.method == "POST": + data = request.get_json(force=True) + sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) + if data.get("id"): + group_users = [ + user_parser(user) + for user in sorted_users + if data.get("id") in user["keycloak_groups"] ] - return json.dumps(users), 200, {"Content-Type": "application/json"} - - -@app.route("/api/users_bulk/", methods=["PUT"]) -@login_required -def users_bulk(action): - data = request.get_json(force=True) - if request.method == "PUT": - if action == "enable": - if "external" in threads.keys(): - if threads["external"] is not None and threads["external"].is_alive(): - return ( - json.dumps( - {"msg": "Precondition failed: already operating users"} - ), - 412, - {"Content-Type": "application/json"}, - ) - else: - threads["external"] = None + elif data.get("path"): try: - threads["external"] = threading.Thread( - target=app.admin.enable_users, args=(data,) - ) - threads["external"].start() - return json.dumps({}), 200, {"Content-Type": "application/json"} + name = [ + g["name"] + for g in app.admin.get_mix_groups() + if g["path"] == data.get("path") + ][0] + group_users = [ + user_parser(user) + for user in sorted_users + if name in user["keycloak_groups"] + ] except: - log.error(traceback.format_exc()) - return ( - json.dumps({"msg": "Enable users error."}), - 500, - {"Content-Type": "application/json"}, - ) - if action == "disable": - if "external" in threads.keys(): - if threads["external"] is not None and threads["external"].is_alive(): - return ( - json.dumps( - {"msg": "Precondition failed: already operating users"} - ), - 412, - {"Content-Type": "application/json"}, - ) - else: - threads["external"] = None + raise Error("not_found", "Group path not found in system") + elif data.get("keycloak_id"): try: - threads["external"] = threading.Thread( - target=app.admin.disable_users, args=(data,) - ) - threads["external"].start() - return json.dumps({}), 200, {"Content-Type": "application/json"} + name = [ + g["name"] + for g in app.admin.get_mix_groups() + if g["id"] == data.get("keycloak_id") + ][0] + group_users = [ + user_parser(user) + for user in sorted_users + if name in user["keycloak_groups"] + ] except: - log.error(traceback.format_exc()) - return ( - json.dumps({"msg": "Disabling users error."}), - 500, - {"Content-Type": "application/json"}, - ) - if action == "delete": - if "external" in threads.keys(): - if threads["external"] is not None and threads["external"].is_alive(): - return ( - json.dumps( - {"msg": "Precondition failed: already operating users"} - ), - 412, - {"Content-Type": "application/json"}, - ) - else: - threads["external"] = None - try: - threads["external"] = threading.Thread( - target=app.admin.delete_users, args=(data,) - ) - threads["external"].start() - return json.dumps({}), 200, {"Content-Type": "application/json"} - except: - log.error(traceback.format_exc()) - return ( - json.dumps({"msg": "Deleting users error."}), - 500, - {"Content-Type": "application/json"}, - ) - return json.dumps({}), 405, {"Content-Type": "application/json"} - - -# Update pwd -@app.route("/api/user_password", methods=["GET"]) -@app.route("/api/user_password/", methods=["PUT"]) -@login_required -def user_password(userid=False): - if request.method == "GET": - return ( - json.dumps(app.admin.get_dice_pwd()), - 200, - {"Content-Type": "application/json"}, - ) - if request.method == "PUT": - data = request.get_json(force=True) - password = data["password"] - temporary = data.get("temporary", True) - try: - res = app.admin.user_update_password(userid, password, temporary) - return json.dumps({}), 200, {"Content-Type": "application/json"} - except KeycloakGetError as e: - log.error(e.error_message.decode("utf-8")) - return ( - json.dumps({"msg": "Update password error."}), - 500, - {"Content-Type": "application/json"}, - ) - - return json.dumps({}), 405, {"Content-Type": "application/json"} - - -# User -@app.route("/api/user", methods=["POST"]) -@app.route("/api/user/", methods=["PUT", "GET", "DELETE"]) -@login_required -def user(userid=None): - if request.method == "DELETE": - app.admin.delete_user(userid) - return json.dumps({}), 200, {"Content-Type": "application/json"} - if request.method == "POST": - data = request.get_json(force=True) - if app.admin.get_user_username(data["username"]): - return ( - json.dumps({"msg": "Add user error: already exists."}), - 409, - {"Content-Type": "application/json"}, - ) - data["enabled"] = True if data.get("enabled", False) else False - data["quota"] = data["quota"] if data["quota"] != "false" else False - data["groups"] = data["groups"] if data.get("groups", False) else [] - if "external" in threads.keys(): - if threads["external"] is not None and threads["external"].is_alive(): - return ( - json.dumps({"msg": "Precondition failed: already adding users"}), - 412, - {"Content-Type": "application/json"}, - ) - else: - threads["external"] = None - try: - threads["external"] = threading.Thread( - target=app.admin.add_user, args=(data,) - ) - threads["external"].start() - return json.dumps({}), 200, {"Content-Type": "application/json"} - except: - log.error(traceback.format_exc()) - return ( - json.dumps({"msg": "Add user error."}), - 500, - {"Content-Type": "application/json"}, - ) - - if request.method == "PUT": - data = request.get_json(force=True) - data["enabled"] = True if data.get("enabled", False) else False - data["groups"] = data["groups"] if data.get("groups", False) else [] - data["roles"] = [data.pop("role-keycloak")] - try: - app.admin.user_update(data) - return json.dumps({}), 200, {"Content-Type": "application/json"} - except UserNotFound: - return ( - json.dumps({"msg": "User not found."}), - 404, - {"Content-Type": "application/json"}, - ) - if request.method == "DELETE": - pass - if request.method == "GET": - user = app.admin.get_user(userid) - if not user: - return ( - json.dumps({"msg": "User not found."}), - 404, - {"Content-Type": "application/json"}, - ) - return json.dumps(user), 200, {"Content-Type": "application/json"} - - -@app.route("/api/roles") -@login_required -def roles(): - sorted_roles = sorted(app.admin.get_roles(), key=lambda k: k["name"]) - if current_user.role != "admin": - sorted_roles = [sr for sr in sorted_roles if sr["name"] != "admin"] - return json.dumps(sorted_roles), 200, {"Content-Type": "application/json"} - - -@app.route("/api/group", methods=["POST", "DELETE"]) -@app.route("/api/group/", methods=["PUT", "GET", "DELETE"]) -@login_required -def group(group_id=False): - if request.method == "POST": - data = request.get_json(force=True) - data["parent"] = data["parent"] if data["parent"] != "" else None - return ( - json.dumps(app.admin.add_group(data)), - 200, - {"Content-Type": "application/json"}, - ) - if request.method == "DELETE": - try: - data = request.get_json(force=True) - except: - data = False - - if data: - res = app.admin.delete_group_by_path(data["path"]) + raise Error("not_found", "Group keycloak_id not found in system") else: - res = app.admin.delete_group_by_id(group_id) - return json.dumps(res), 200, {"Content-Type": "application/json"} + raise Error("bad_request", "Incorrect data requested.") + return json.dumps(group_users), 200, {"Content-Type": "application/json"} -@app.route("/api/groups") -@app.route("/api/groups/", methods=["POST", "PUT", "GET", "DELETE"]) -@login_required -def groups(provider=False): +@app.route("/ddapi/roles", methods=["GET"]) +@has_token +def ddapi_roles(): if request.method == "GET": - sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: str(k["name"])) - if current_user.role != "admin": - ## internal groups should be avoided as are assigned with the role - sorted_groups = [sg for sg in sorted_groups if not system_group(sg["name"])] - else: - sorted_groups = [sg for sg in sorted_groups] - return json.dumps(sorted_groups), 200, {"Content-Type": "application/json"} - if request.method == "DELETE": - if provider == "keycloak": - return ( - json.dumps(app.admin.delete_keycloak_groups()), - 200, - {"Content-Type": "application/json"}, - ) - - -### SYSADM USERS ONLY - - -@app.route("/api/external", methods=["POST", "PUT", "GET", "DELETE"]) -@login_required -def external(): - if "external" in threads.keys(): - if threads["external"] is not None and threads["external"].is_alive(): - return json.dumps({}), 301, {"Content-Type": "application/json"} - else: - threads["external"] = None - - if request.method == "POST": - data = request.get_json(force=True) - if data["format"] == "json-ga": - threads["external"] = threading.Thread( - target=app.admin.upload_json_ga, args=(data,) - ) - threads["external"].start() - return json.dumps({}), 200, {"Content-Type": "application/json"} - if data["format"] == "csv-ug": - valid = check_upload_errors(data) - if valid["pass"]: - threads["external"] = threading.Thread( - target=app.admin.upload_csv_ug, args=(data,) - ) - threads["external"].start() - return json.dumps({}), 200, {"Content-Type": "application/json"} - else: - return json.dumps(valid), 422, {"Content-Type": "application/json"} - if request.method == "PUT": - data = request.get_json(force=True) - threads["external"] = threading.Thread( - target=app.admin.sync_external, args=(data,) - ) - threads["external"].start() - return json.dumps({}), 200, {"Content-Type": "application/json"} - if request.method == "DELETE": - print("RESET") - app.admin.reset_external() - return json.dumps({}), 200, {"Content-Type": "application/json"} - return json.dumps({}), 500, {"Content-Type": "application/json"} - - -@app.route("/api/external/users") -@login_required -def external_users_list(): - while threads["external"] is not None and threads["external"].is_alive(): - time.sleep(0.5) - return ( - json.dumps(app.admin.get_external_users()), - 200, - {"Content-Type": "application/json"}, - ) - - -@app.route("/api/external/groups") -@login_required -def external_groups_list(): - while threads["external"] is not None and threads["external"].is_alive(): - time.sleep(0.5) - return ( - json.dumps(app.admin.get_external_groups()), - 200, - {"Content-Type": "application/json"}, - ) - - -@app.route("/api/external/roles", methods=["PUT"]) -@login_required -def external_roles(): - if request.method == "PUT": - return ( - json.dumps(app.admin.external_roleassign(request.get_json(force=True))), - 200, - {"Content-Type": "application/json"}, - ) - - -def check_upload_errors(data): - email_regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b" - for u in data["data"]: - try: - user_groups = [g.strip() for g in u["groups"].split(",")] - except: - resp = { - "pass": False, - "msg": "User " + u["username"] + " has invalid groups: " + u["groups"], - } - log.error(resp) - return resp - - if not re.fullmatch(email_regex, u["email"]): - resp = { - "pass": False, - "msg": "User " + u["username"] + " has invalid email: " + u["email"], - } - log.error(resp) - return resp - - if u["role"] not in ["admin", "manager", "teacher", "student"]: - if u["role"] == "": - resp = { - "pass": False, - "msg": "User " + u["username"] + " has no role assigned!", + roles = [] + for role in sorted(app.admin.get_roles(), key=lambda k: k["name"]): + log.error(role) + roles.append( + { + "keycloak_id": role["id"], + "id": role["name"], + "name": role["name"], + "description": role.get("description", ""), } - log.error(resp) - return resp - resp = { - "pass": False, - "msg": "User " + u["username"] + " has invalid role: " + u["role"], - } - log.error(resp) - return resp - return {"pass": True, "msg": ""} - - -@app.route("/api/dashboard/", methods=["PUT"]) -# @login_required -def dashboard_put(item): - if item == "colours": - try: - data = request.get_json(force=True) - dashboard.update_colours(data) - except: - log.error(traceback.format_exc()) - return json.dumps({"colours": data}), 200, {"Content-Type": "application/json"} - if item == "menu": - try: - data = request.get_json(force=True) - dashboard.update_menu(data) - except: - log.error(traceback.format_exc()) - return json.dumps(data), 200, {"Content-Type": "application/json"} - if item == "logo": - dashboard.update_logo(request.files["croppedImage"]) - return json.dumps({}), 200, {"Content-Type": "application/json"} - if item == "background": - dashboard.update_background(request.files["croppedImage"]) - return json.dumps({}), 200, {"Content-Type": "application/json"} - return ( - json.dumps( - { - "error": "update_error", - "msg": "Error updating item " + item + "\n" + traceback.format_exc(), - } - ), - 500, - {"Content-Type": "application/json"}, - ) - - -@app.route("/api/legal/", methods=["GET", "POST"]) -# @login_required -def legal_put(item): - if request.method == "GET": - if item == "legal": - lang = request.args.get("lang") - return ( - json.dumps({"html": "Legal
This works! in lang: " + lang}), - 200, - {"Content-Type": "application/json"}, ) - # if item == "privacy": - # return json.dumps({ "html": "Privacy policy
This works!"}), 200, {'Content-Type': 'application/json'} + return json.dumps(roles), 200, {"Content-Type": "application/json"} + + +@app.route("/ddapi/role/users", methods=["POST"]) +@has_token +def ddapi_role_users(): if request.method == "POST": - if item == "legal": - data = None + data = request.get_json(force=True) + sorted_users = sorted(app.admin.get_mix_users(), key=lambda k: k["username"]) + if data.get("id", data.get("name")): + role_users = [ + user_parser(user) + for user in sorted_users + if data.get("id", data.get("name")) in user["roles"] + ] + elif data.get("keycloak_id"): try: - data = request.json - html = data["html"] - lang = data["lang"] + id = [ + r["id"] + for r in app.admin.get_roles() + if r["id"] == data.get("keycloak_id") + ][0] + role_users = [ + user_parser(user) for user in sorted_users if id in user["roles"] + ] except: - log.error(traceback.format_exc()) - return json.dumps(data), 200, {"Content-Type": "application/json"} - # if item == "privacy": - # data = None - # try: - # data = request.json - # html = data["html"] - # lang = data["lang"] - # except: - # log.error(traceback.format_exc()) - # return json.dumps(data), 200, {'Content-Type': 'application/json'} + raise Error("not_found", "Role keycloak_id not found in system") + else: + raise Error("bad_request", "Incorrect data requested.") + return json.dumps(role_users), 200, {"Content-Type": "application/json"} + + +## INDIVIDUAL ACTIONS +@app.route("/ddapi/user", methods=["POST"]) +@app.route("/ddapi/user/", methods=["PUT", "GET", "DELETE"]) +@has_token +def ddapi_user(user_ddid=None): + if request.method == "GET": + user = app.admin.get_user_username(user_ddid) + if not user: + raise Error("not_found", "User id not found") + return json.dumps(user_parser(user)), 200, {"Content-Type": "application/json"} + if request.method == "DELETE": + user = app.admin.get_user_username(user_ddid) + if not user: + raise Error("not_found", "User id not found") + app.admin.delete_user(user["id"]) + return json.dumps({}), 200, {"Content-Type": "application/json"} + if request.method == "POST": + data = request.get_json(force=True) + if not app.validators["user"].validate(data): + raise Error( + "bad_request", + "Data validation for user failed: ", + +str(app.validators["user"].errors), + traceback.format_exc(), + ) + + if app.admin.get_user_username(data["username"]): + raise Error("conflict", "User id already exists") + keycloak_id = app.admin.add_user(data) + return ( + json.dumps({"keycloak_id": keycloak_id}), + 200, + {"Content-Type": "application/json"}, + ) + + if request.method == "PUT": + user = app.admin.get_user_username(user_ddid) + if not user: + raise Error("not_found", "User id not found") + data = request.get_json(force=True) + if not app.validators["user_update"].validate(data): + raise Error( + "bad_request", + "Data validation for user failed: " + + str(app.validators["user_update"].errors), + traceback.format_exc(), + ) + data = {**user, **data} + data = app.validators["user_update"].normalized(data) + data = {**data, **{"username": user_ddid}} + data["roles"] = [data.pop("role")] + data["firstname"] = data.pop("first") + data["lastname"] = data.pop("last") + app.admin.user_update(data) + if data.get("password"): + app.admin.user_update_password( + user["id"], data["password"], data["password_temporary"] + ) + return json.dumps({}), 200, {"Content-Type": "application/json"} + + +@app.route("/ddapi/username//", methods=["PUT"]) +@has_token +def ddapi_username(old_user_ddid, new_user_did): + user = app.admin.get_user_username(user_ddid) + if not user: + raise Error("not_found", "User id not found") + # user = app.admin.update_user_username(old_user_ddid,new_user_did) + return json.dumps("Not implemented yet!"), 419, {"Content-Type": "application/json"} + + +@app.route("/ddapi/group", methods=["POST"]) +@app.route("/ddapi/group/", methods=["GET", "POST", "DELETE"]) +# @app.route("/api/group/", methods=["PUT", "GET", "DELETE"]) +@has_token +def ddapi_group(id=None): + if request.method == "GET": + group = app.admin.get_group_by_name(id) + if not group: + Error("not found", "Group id not found") + return ( + json.dumps(group_parser(group)), + 200, + {"Content-Type": "application/json"}, + ) + if request.method == "POST": + data = request.get_json(force=True) + if not app.validators["group"].validate(data): + raise Error( + "bad_request", + "Data validation for group failed: " + + str(app.validators["group"].errors), + traceback.format_exc(), + ) + data = app.validators["group"].normalized(data) + data["parent"] = data["parent"] if data["parent"] != "" else None + + if app.admin.get_group_by_name(id): + raise Error("conflict", "Group id already exists") + + path = app.admin.add_group(data) + # log.error(path) + # keycloak_id = app.admin.get_group_by_name(id)["id"] + # log.error() + return ( + json.dumps({"keycloak_id": None}), + 200, + {"Content-Type": "application/json"}, + ) + if request.method == "DELETE": + group = app.admin.get_group_by_name(id) + if not group: + raise Error("not_found", "Group id not found") + app.admin.delete_group_by_id(group["id"]) + return json.dumps({}), 200, {"Content-Type": "application/json"} + + +def user_parser(user): + return { + "keycloak_id": user["id"], + "id": user["username"], + "username": user["username"], + "enabled": user["enabled"], + "first": user["first"], + "last": user["last"], + "role": user["roles"][0] if len(user["roles"]) else None, + "email": user["email"], + "groups": user.get("groups", user["keycloak_groups"]), + "quota": user["quota"], + "quota_used_bytes": user["quota_used_bytes"], + } + + +def group_parser(group): + return { + "keycloak_id": group["id"], + "id": group["name"], + "name": group["name"].split(".")[-1], + "path": group["path"], + "description": group.get("description", ""), + } + + +def filter_users(users, text): + return [ + user + for user in users + if text in user["username"] + or text in user["first"] + or text in user["last"] + or text in user["email"] + ] diff --git a/admin/src/admin/views/InternalViews.py b/admin/src/admin/views/WpViews.py similarity index 95% rename from admin/src/admin/views/InternalViews.py rename to admin/src/admin/views/WpViews.py index b99e00e..0c829dd 100644 --- a/admin/src/admin/views/InternalViews.py +++ b/admin/src/admin/views/WpViews.py @@ -3,6 +3,7 @@ import json import logging as log import os +import socket import sys import time import traceback @@ -11,12 +12,11 @@ from flask import request from admin import app -from .decorators import is_internal, is_internal_or_has_token +from .decorators import is_internal -import socket @app.route("/api/internal/users", methods=["GET"]) -@is_internal_or_has_token +@is_internal def internal_users(): log.error(socket.gethostbyname("isard-apps-wordpress")) if request.method == "GET": @@ -31,7 +31,7 @@ def internal_users(): @app.route("/api/internal/users/filter", methods=["POST"]) -@is_internal_or_has_token +@is_internal def internal_users_search(): if request.method == "POST": data = request.get_json(force=True) @@ -42,7 +42,7 @@ def internal_users_search(): @app.route("/api/internal/groups", methods=["GET"]) -@is_internal_or_has_token +@is_internal def internal_groups(): if request.method == "GET": sorted_groups = sorted(app.admin.get_mix_groups(), key=lambda k: k["name"]) @@ -61,7 +61,7 @@ def internal_groups(): @app.route("/api/internal/group/users", methods=["POST"]) -@is_internal_or_has_token +@is_internal def internal_group_users(): if request.method == "POST": data = request.get_json(force=True) @@ -80,7 +80,7 @@ def internal_group_users(): @app.route("/api/internal/roles", methods=["GET"]) -@is_internal_or_has_token +@is_internal def internal_roles(): if request.method == "GET": roles = [] @@ -98,7 +98,7 @@ def internal_roles(): @app.route("/api/internal/role/users", methods=["POST"]) -@is_internal_or_has_token +@is_internal def internal_role_users(): if request.method == "POST": data = request.get_json(force=True) diff --git a/admin/src/admin/views/decorators.py b/admin/src/admin/views/decorators.py index 441fde8..1497fd4 100644 --- a/admin/src/admin/views/decorators.py +++ b/admin/src/admin/views/decorators.py @@ -1,15 +1,17 @@ #!flask/bin/python # coding=utf-8 +import json +import logging as log +import os import socket from functools import wraps -import json -import os -from jose import jwt -from ..auth.tokens import get_header_jwt_payload from flask import redirect, request, url_for from flask_login import current_user, logout_user +from jose import jwt + +from ..auth.tokens import get_header_jwt_payload def is_admin(fn): @@ -34,22 +36,29 @@ def is_internal(fn): ## but we should check if it is internal net and not haproxy if socket.gethostbyname("isard-apps-wordpress") == remote_addr: return fn(*args, **kwargs) - logout_user() - return redirect(url_for("login")) + return ( + json.dumps( + { + "error": "unauthorized", + "msg": "Unauthorized access", + } + ), + 401, + {"Content-Type": "application/json"}, + ) return decorated_view + def has_token(fn): @wraps(fn) def decorated(*args, **kwargs): payload = get_header_jwt_payload() - # if payload.get("role_id") != "admin": - # maintenance() - kwargs["payload"] = payload return fn(*args, **kwargs) return decorated + def is_internal_or_has_token(fn): @wraps(fn) def decorated_view(*args, **kwargs): @@ -58,32 +67,22 @@ def is_internal_or_has_token(fn): if "X-Forwarded-For" in request.headers else request.remote_addr.split(",")[0] ) - ## Now only checks if it is wordpress container, - ## but we should check if it is internal net and not haproxy - valid_jwt = False - try: - payload = get_header_jwt_payload() - valid_jwt = True - except: - valid_jwt = False - if valid_jwt: - return fn(*args, **kwargs) - else: - return ( - json.dumps( - { - "error": "unauthorized", - "msg": "Unauthorized access", - } - ), - 401, - {"Content-Type": "application/json"}, - ) + payload = get_header_jwt_payload() if socket.gethostbyname("isard-apps-wordpress") == remote_addr: return fn(*args, **kwargs) - else: - logout_user() - return redirect(url_for("login")) + payload = get_header_jwt_payload() + return fn(*args, **kwargs) - return decorated_view \ No newline at end of file + return decorated_view + + +def login_or_token(fn): + @wraps(fn) + def decorated_view(*args, **kwargs): + if current_user.is_authenticated: + return fn(*args, **kwargs) + payload = get_header_jwt_payload() + return fn(*args, **kwargs) + + return decorated_view diff --git a/admin/src/start.py b/admin/src/start.py index 9111d3d..df970d5 100644 --- a/admin/src/start.py +++ b/admin/src/start.py @@ -41,9 +41,9 @@ if __name__ == "__main__": app, host="0.0.0.0", port=9000, - debug=False, - # ssl_context="adhoc", - # async_mode="threading", - ) # , logger=logger, engineio_logger=engineio_logger) + debug=True, + ) + # ssl_context="adhoc", + # async_mode="threading", + # ) # , logger=logger, engineio_logger=engineio_logger) # , cors_allowed_origins="*" -# /usr/lib/python3.8/site-packages/certifi