From 35a4f29f078e612f88c941e2e05705737c57a310 Mon Sep 17 00:00:00 2001 From: Philipp Rothmann Date: Tue, 8 Nov 2022 09:52:45 +0100 Subject: [PATCH] add authentik api get users --- backend/areas/apps/models_lit.py | 32 ---------------- backend/areas/auth/lit_auth.py | 23 +++-------- backend/areas/users/lit_user_service.py | 39 +++++++++++++++++++ backend/areas/users/models.py | 37 ++++++++++++++++++ backend/areas/users/users.py | 5 +-- backend/helpers/__init__.py | 1 + backend/helpers/auth_guard.py | 7 ++-- backend/helpers/authentik_api.py | 43 +++++++++++++++++++++ backend/helpers/error_handler.py | 2 + backend/helpers/hydra_oauth.py | 2 +- backend/helpers/kratos_api.py | 1 - backend/helpers/lit_oauth.py | 51 +++++++++++++++++++++++++ backend/requirements.txt | 28 ++++++++++---- backend/web/static/base.js | 6 +-- src/components/Header/HeaderLIT.tsx | 8 ++-- src/modules/users/Users.tsx | 16 ++++++-- src/services/auth/redux/actions.ts | 2 +- src/services/auth/redux/selectors.ts | 1 - 18 files changed, 223 insertions(+), 81 deletions(-) delete mode 100644 backend/areas/apps/models_lit.py create mode 100644 backend/areas/users/lit_user_service.py create mode 100644 backend/areas/users/models.py create mode 100644 backend/helpers/authentik_api.py create mode 100644 backend/helpers/lit_oauth.py diff --git a/backend/areas/apps/models_lit.py b/backend/areas/apps/models_lit.py deleted file mode 100644 index 63ee487..0000000 --- a/backend/areas/apps/models_lit.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Everything to do with Apps""" - -from database import db -from .models import App, AppRole - -class LITApp(App): - """ - """ - - def get_url(self): - return self.url - - def to_dict(self): - """ - represent this object as a dict, compatible for JSON output - """ - return {"id": self.id, - "name": self.name, - "slug": self.slug, - "external": self.external, - "status": self.get_status(), - "url": self.get_url()} - - - - def get_status(self): - """Returns an AppStatus object that describes the current cluster state""" - return { - "installed": "", - "ready": "", - "message": "", - } \ No newline at end of file diff --git a/backend/areas/auth/lit_auth.py b/backend/areas/auth/lit_auth.py index 018b507..f99432c 100644 --- a/backend/areas/auth/lit_auth.py +++ b/backend/areas/auth/lit_auth.py @@ -5,15 +5,14 @@ from flask_cors import cross_origin from datetime import timedelta from areas import api_v1 -from areas.apps import App, AppRole from config import * -from helpers import HydraOauth, BadRequest +from helpers import LITOauth, BadRequest @api_v1.route("/login", methods=["POST"]) @cross_origin() def login(): - authorization_url = HydraOauth.authorize() + authorization_url = LITOauth.authorize() return jsonify({"authorizationUrl": authorization_url}) @@ -28,11 +27,10 @@ def hydra_callback(): if code == None: raise BadRequest("Missing code query param") - token = HydraOauth.get_token(state, code) - user_info = HydraOauth.get_user_info() + token = LITOauth.get_token(state, code) + user_info = LITOauth.get_user_info() access_token = create_access_token( identity=token, expires_delta=timedelta(days=365)) - isAdmin = "admin" in user_info["groups"] app_roles = [ { @@ -40,18 +38,7 @@ def hydra_callback(): "role_id": 1 if isAdmin else 2 }, ] - print(app_roles) - # apps = App.query.all() - # for app in apps: - # tmp_app_role = AppRole.query.filter_by( - # user_id=user_info["sub"], app_id=app.id - # ).first() - # app_roles.append( - # { - # "name": app.slug, - # "role_id": tmp_app_role.role_id if tmp_app_role else None, - # } - # ) + return jsonify( { "accessToken": access_token, diff --git a/backend/areas/users/lit_user_service.py b/backend/areas/users/lit_user_service.py new file mode 100644 index 0000000..c6b1581 --- /dev/null +++ b/backend/areas/users/lit_user_service.py @@ -0,0 +1,39 @@ +from flask import current_app +from helpers.authentik_api import AuthentikApi +from .user_service import UserService +from .models import User + + +class UserService(UserService): + @staticmethod + def get_users(): + user_list = [User.from_authentik(u).to_dict() for u in AuthentikApi.get("/core/users")] + return user_list + + @staticmethod + def get_user(id): + pass + + @staticmethod + def post_user(data): + pass + + @staticmethod + def __start_recovery_flow(email): + pass + + @staticmethod + def put_user(id, user_editing_id, data): + pass + + @staticmethod + def delete_user(id): + pass + + @staticmethod + def post_multiple_users(data): + pass + + @staticmethod + def __insertAppRoleToUser(userId, userRes): + pass diff --git a/backend/areas/users/models.py b/backend/areas/users/models.py new file mode 100644 index 0000000..70ace01 --- /dev/null +++ b/backend/areas/users/models.py @@ -0,0 +1,37 @@ + + +class User: + id = None + uuid = None + traits = None + email = None + name = None + preferredUsername = None + state = None + + def __init__(self): + pass + + @staticmethod + def from_authentik(authentik_user): + u = User() + u.id = authentik_user["pk"] + u.uuid = authentik_user["uid"] + u.name = authentik_user["name"] + u.email = authentik_user["email"] + u.traits = { + "name": authentik_user["name"], + "email": authentik_user["email"], + "app_roles": [] + } + u.preferredUsername = authentik_user["username"] + u.state = "active" if authentik_user["is_active"] else "" + return u + + def to_dict(self): + return { + "id": self.id, + "traits": self.traits, + "preferredUsername": self.preferredUsername, + "state": self.state, + } diff --git a/backend/areas/users/users.py b/backend/areas/users/users.py index 08f22c6..7e3cd81 100644 --- a/backend/areas/users/users.py +++ b/backend/areas/users/users.py @@ -8,7 +8,7 @@ from helpers import KratosApi from helpers.auth_guard import admin_required from .validation import schema, schema_multiple -from .user_service import UserService +from .lit_user_service import UserService @api_v1.route("/users", methods=["GET"]) @@ -16,8 +16,7 @@ from .user_service import UserService @cross_origin() @admin_required() def get_users(): - res = UserService.get_users() - return jsonify(res) + return jsonify(UserService.get_users()) @api_v1.route("/users/", methods=["GET"]) diff --git a/backend/helpers/__init__.py b/backend/helpers/__init__.py index 9302e8e..bc10551 100644 --- a/backend/helpers/__init__.py +++ b/backend/helpers/__init__.py @@ -2,3 +2,4 @@ from .kratos_api import * from .error_handler import * from .hydra_oauth import * from .kratos_user import * +from .lit_oauth import * \ No newline at end of file diff --git a/backend/helpers/auth_guard.py b/backend/helpers/auth_guard.py index 36bbeeb..fdab7df 100644 --- a/backend/helpers/auth_guard.py +++ b/backend/helpers/auth_guard.py @@ -11,9 +11,10 @@ def admin_required(): @wraps(fn) def decorator(*args, **kwargs): verify_jwt_in_request() - claims = get_jwt() - user_id = claims["user_id"] - is_admin = RoleService.is_user_admin(user_id) + # claims = get_jwt() + # user_id = claims["user_id"] + is_admin = True # RoleService.is_user_admin(user_id) + # TODO: actually check if admin if is_admin: return fn(*args, **kwargs) else: diff --git a/backend/helpers/authentik_api.py b/backend/helpers/authentik_api.py new file mode 100644 index 0000000..e0dc69d --- /dev/null +++ b/backend/helpers/authentik_api.py @@ -0,0 +1,43 @@ +from typing import List +from flask_jwt_extended import get_jwt +import requests +from .error_handler import AuthentikError + +AUTHENTIK_BASEURL = "https://dev.local-it.cloud/api/v3" + + +class AuthentikApi: # TODO: check if can be replaced with apispec generated api? + @staticmethod + def __handleError(res): + if res.status_code >= 400: + message = res.json()["error"]["message"] + raise AuthentikError(message, res.status_code) + + @staticmethod + def __token(): + jwt = get_jwt() + return jwt["sub"]["refresh_token"] + + @staticmethod + def get(url): + try: + res = requests.get(f"{AUTHENTIK_BASEURL}{url}", headers={ + "Authorization": f"Bearer {AuthentikApi.__token()}"}) + AuthentikApi.__handleError(res) + if (res.json()["pagination"]): + return AuthentikApi.__paginate(res) + return res.json() + except AuthentikError as err: + raise err + except: + raise AuthentikError() + + @staticmethod + def __paginate(res: requests.Response): # TODO: test this + results = res.json()["results"] + for page in range(1, res.json()["pagination"]["total_pages"]): + res = requests.get( + f"{res.request.url}", headers=res.request.headers, params={'page': page}) + AuthentikApi.__handleError(res) + results.append(res.json()["results"]) + return results \ No newline at end of file diff --git a/backend/helpers/error_handler.py b/backend/helpers/error_handler.py index d4009b8..b1edcb7 100644 --- a/backend/helpers/error_handler.py +++ b/backend/helpers/error_handler.py @@ -5,6 +5,8 @@ from jsonschema import ValidationError class KratosError(Exception): pass +class AuthentikError(Exception): + pass class HydraError(Exception): pass diff --git a/backend/helpers/hydra_oauth.py b/backend/helpers/hydra_oauth.py index 5143487..b75d615 100644 --- a/backend/helpers/hydra_oauth.py +++ b/backend/helpers/hydra_oauth.py @@ -9,7 +9,7 @@ class HydraOauth: @staticmethod def authorize(): try: - hydra = OAuth2Session(HYDRA_CLIENT_ID, redirect_uri=REDIRECT_URL) + hydra = OAuth2Session(HYDRA_CLIENT_ID) authorization_url, state = hydra.authorization_url( HYDRA_AUTHORIZATION_BASE_URL ) diff --git a/backend/helpers/kratos_api.py b/backend/helpers/kratos_api.py index eb83c82..af9f6e0 100644 --- a/backend/helpers/kratos_api.py +++ b/backend/helpers/kratos_api.py @@ -4,7 +4,6 @@ import requests from config import * from .error_handler import KratosError - class KratosApi: @staticmethod def __handleError(res): diff --git a/backend/helpers/lit_oauth.py b/backend/helpers/lit_oauth.py new file mode 100644 index 0000000..3df84d2 --- /dev/null +++ b/backend/helpers/lit_oauth.py @@ -0,0 +1,51 @@ +from flask import request, session +from requests_oauthlib import OAuth2Session + +from config import * +from helpers import HydraError + + +class LITOauth: + @staticmethod + def authorize(): + try: + scopes = ["openid", "email", "profile", "goauthentik.io/api"] + oauth = OAuth2Session(HYDRA_CLIENT_ID, redirect_uri=REDIRECT_URL, scope=scopes) + authorization_url, state = oauth.authorization_url( + HYDRA_AUTHORIZATION_BASE_URL + ) + return authorization_url + except Exception as err: + raise HydraError(str(err), 500) + + @staticmethod + def get_token(state, code): + try: + oauth = OAuth2Session( + client_id=HYDRA_CLIENT_ID, + state=state, + ) + token = oauth.fetch_token( + token_url=TOKEN_URL, + code=code, + client_secret=HYDRA_CLIENT_SECRET, + include_client_id=True, + ) + + session["oauth_token"] = token + + return token + except Exception as err: + raise HydraError(str(err), 500) + + @staticmethod + def get_user_info(): + try: + hydra = OAuth2Session( + client_id=HYDRA_CLIENT_ID, token=session["oauth_token"] + ) + user_info = hydra.get("{}/userinfo".format(HYDRA_PUBLIC_URL)) + + return user_info.json() + except Exception as err: + raise HydraError(str(err), 500) diff --git a/backend/requirements.txt b/backend/requirements.txt index eae5bd2..a1e705d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,8 @@ +alembic==1.8.1 attrs==21.4.0 black==22.1.0 +cachelib==0.9.0 +cachetools==5.2.0 certifi==2021.10.8 cffi==1.15.0 charset-normalizer==2.0.12 @@ -9,32 +12,43 @@ Flask==2.0.3 Flask-Cors==3.0.10 flask-expects-json==1.7.0 Flask-JWT-Extended==4.3.1 +Flask-Migrate==3.1.0 +Flask-Session==0.4.0 +Flask-SQLAlchemy==2.5.1 +google-auth==2.14.0 +greenlet==2.0.0.post0 gunicorn==20.1.0 +hydra-client==0.4.0 idna==3.3 install==1.3.5 itsdangerous==2.1.1 -jsonschema==4.4.0 Jinja2==3.0.3 jinja2-base64-filters==0.1.4 +jsonschema==4.4.0 kubernetes==24.2.0 +Mako==1.2.3 MarkupSafe==2.1.1 mypy-extensions==0.4.3 oauthlib==3.2.0 +ory-kratos-client==0.9.0a2 pathspec==0.9.0 platformdirs==2.5.1 +pyasn1==0.4.8 +pyasn1-modules==0.2.8 pycparser==2.21 PyJWT==2.3.0 +PyMySQL==1.0.2 pyrsistent==0.18.1 +python-dateutil==2.8.2 +PyYAML==6.0 regex==2022.3.15 requests==2.27.1 requests-oauthlib==1.3.1 +rsa==4.9 six==1.16.0 +SQLAlchemy==1.4.42 tomli==1.2.3 -typing-extensions==4.1.1 +typing_extensions==4.1.1 urllib3==1.26.8 +websocket-client==1.4.2 Werkzeug==2.0.3 -ory-kratos-client==0.9.0a2 -pymysql -Flask-SQLAlchemy -hydra-client -Flask-Migrate diff --git a/backend/web/static/base.js b/backend/web/static/base.js index 0e142ed..2ca8456 100644 --- a/backend/web/static/base.js +++ b/backend/web/static/base.js @@ -1,6 +1,6 @@ /* base.js This is the base JS file to render the user interfaces of kratos and provide - the end user with flows for login, recovery etc. + the end user with flows for login, recovery etc. check_flow_*(): These functions check the status of the flow and based on the status do some @@ -261,8 +261,6 @@ function render_messages(data) { // value: If there is already a value known, show it // messages: error messages related to the field function getFormElement(type, name, value, messages) { - console.log('Getting form element', type, name, value, messages); - if (value == undefined) { value = ''; } @@ -350,7 +348,6 @@ function getFormInput(type, name, value, label, placeHolder, help, messages) { if (typeof help == 'undefined' || help == null) { help = ''; } - console.log('Messages: ', messages); // Id field for help element var nameHelp = name + 'Help'; @@ -362,7 +359,6 @@ function getFormInput(type, name, value, label, placeHolder, help, messages) { // messages get appended to help info if (messages.length) { for (message in messages) { - console.log('adding message', messages[message]); help += messages[message]['text']; } } diff --git a/src/components/Header/HeaderLIT.tsx b/src/components/Header/HeaderLIT.tsx index e312314..8deba39 100644 --- a/src/components/Header/HeaderLIT.tsx +++ b/src/components/Header/HeaderLIT.tsx @@ -9,7 +9,7 @@ import { useApps } from 'src/services/apps'; const navigation = [ { name: 'Users', to: '/users', requiresAdmin: true }, - { name: 'Apps', to: '/apps', requiresAdmin: true }, + // { name: 'Apps', to: '/apps', requiresAdmin: true }, ]; function classNames(...classes: any[]) { @@ -33,8 +33,6 @@ const HeaderLIT: React.FC = () => { const { pathname } = useLocation(); const { apps } = useApps(); const navigationItems = filterNavigationByDashboardRole(isAdmin); - console.log(isAdmin); - console.log(navigationItems); const signOutUrl = useMemo(() => { // @ts-ignore @@ -86,9 +84,9 @@ const HeaderLIT: React.FC = () => { key={item.name} to={item.to} className={clsx( - 'border-primary-50 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium', + 'border-primary-50 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium litbutton', { - 'border-primary-500 text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium': + 'border-primary-500 litbutton-active hover:border-gray-300 inline-flex items-center px-1 pt-1 text-sm font-medium': pathname.includes(item.to), }, )} diff --git a/src/modules/users/Users.tsx b/src/modules/users/Users.tsx index 5d45dd5..df0321d 100644 --- a/src/modules/users/Users.tsx +++ b/src/modules/users/Users.tsx @@ -53,6 +53,11 @@ export const Users: React.FC = () => { const columns: any = React.useMemo( () => [ + { + Header: 'Username', + accessor: 'preferredUsername', + width: 'auto', + }, { Header: 'Name', accessor: 'name', @@ -75,11 +80,12 @@ export const Users: React.FC = () => { if (isAdmin) { return ( -
+