introduce admin area
introduce admin area first poc for connecting the authentik api Co-authored-by: Philipp Rothmann <philipprothmann@posteo.de> Reviewed-on: #2
This commit is contained in:
parent
8d760e588f
commit
44e4e4eb42
35 changed files with 367 additions and 133 deletions
|
|
@ -7,3 +7,4 @@ REDIRECT_URL="https://example.org/login-callback"
|
|||
SECRET_KEY=
|
||||
LOAD_INCLUSTER_CONFIG=false
|
||||
DATABASE_URL=sqlite:///database.db
|
||||
AUTHENTIK_BASEURL="https://sso.example.org/api/v3"
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
tag = "$$(git describe --tags)"
|
||||
|
||||
build:
|
||||
docker build -t dashboard-backend .
|
||||
docker tag dashboard-backend yksflip/dashboard-backend:latest
|
||||
docker tag dashboard-backend yksflip/dashboard-backend:$(tag)
|
||||
docker push yksflip/dashboard-backend:$(tag)
|
||||
|
||||
clean:
|
||||
rm database.db
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
"""Everything to do with Apps"""
|
||||
|
||||
from database import db
|
||||
from .models import App
|
||||
|
||||
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": "",
|
||||
}
|
||||
|
|
@ -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,26 +27,17 @@ 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),
|
||||
#additional_claims={"user_id": identity["id"]}
|
||||
)
|
||||
|
||||
# apps = App.query.all()
|
||||
# app_roles = []
|
||||
# for app in apps:
|
||||
# tmp_app_role = AppRole.query.filter_by(
|
||||
# user_id=identity["id"], app_id=app.id
|
||||
# ).first()
|
||||
# app_roles.append(
|
||||
# {
|
||||
# "name": app.slug,
|
||||
# "role_id": tmp_app_role.role_id if tmp_app_role else None,
|
||||
# }
|
||||
# )
|
||||
identity=token, expires_delta=timedelta(days=365))
|
||||
isAdmin = "admin" in user_info["groups"]
|
||||
app_roles = [
|
||||
{
|
||||
"name": "dashboard",
|
||||
"role_id": 1 if isAdmin else 2
|
||||
},
|
||||
]
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
|
|
@ -57,7 +47,7 @@ def hydra_callback():
|
|||
"email": user_info["email"],
|
||||
"name": user_info["name"],
|
||||
"preferredUsername": user_info["preferred_username"],
|
||||
# "app_roles": app_roles,
|
||||
},
|
||||
"app_roles": app_roles
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
39
backend/areas/users/lit_user_service.py
Normal file
39
backend/areas/users/lit_user_service.py
Normal file
|
|
@ -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
|
||||
37
backend/areas/users/models.py
Normal file
37
backend/areas/users/models.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -8,16 +8,15 @@ 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"])
|
||||
@jwt_required()
|
||||
@cross_origin()
|
||||
@admin_required()
|
||||
# @admin_required() TODO: not needed as authentik checks permissions?
|
||||
def get_users():
|
||||
res = UserService.get_users()
|
||||
return jsonify(res)
|
||||
return jsonify(UserService.get_users())
|
||||
|
||||
|
||||
@api_v1.route("/users/<string:id>", methods=["GET"])
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import os
|
||||
|
||||
|
||||
def env_file(key: str):
|
||||
file_env = os.environ.get(f"{key}_FILE")
|
||||
if file_env and os.path.exists(file_env):
|
||||
return open(file_env).read().rstrip('\n')
|
||||
return os.environ.get(key)
|
||||
|
||||
|
||||
SECRET_KEY = env_file("SECRET_KEY")
|
||||
|
||||
HYDRA_CLIENT_ID = os.environ.get("HYDRA_CLIENT_ID")
|
||||
|
|
@ -28,4 +30,7 @@ SQLALCHEMY_TRACK_MODIFICATIONS = False
|
|||
# Set this to "true" to load the config from a Kubernetes serviceaccount
|
||||
# running in a Kubernetes pod. Set it to "false" to load the config from the
|
||||
# `KUBECONFIG` environment variable.
|
||||
LOAD_INCLUSTER_CONFIG = os.environ.get("LOAD_INCLUSTER_CONFIG").lower() == "true"
|
||||
LOAD_INCLUSTER_CONFIG = os.environ.get(
|
||||
"LOAD_INCLUSTER_CONFIG").lower() == "true"
|
||||
|
||||
AUTHENTIK_BASEURL = os.environ.get("AUTHENTIK_BASEURL")
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ from .kratos_api import *
|
|||
from .error_handler import *
|
||||
from .hydra_oauth import *
|
||||
from .kratos_user import *
|
||||
from .lit_oauth import *
|
||||
|
|
@ -12,6 +12,7 @@ def admin_required():
|
|||
def decorator(*args, **kwargs):
|
||||
verify_jwt_in_request()
|
||||
claims = get_jwt()
|
||||
|
||||
user_id = claims["user_id"]
|
||||
is_admin = RoleService.is_user_admin(user_id)
|
||||
if is_admin:
|
||||
|
|
|
|||
42
backend/helpers/authentik_api.py
Normal file
42
backend/helpers/authentik_api.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from typing import List
|
||||
from flask_jwt_extended import get_jwt
|
||||
import requests
|
||||
|
||||
from config import AUTHENTIK_BASEURL
|
||||
from .error_handler import AuthentikError
|
||||
|
||||
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 ("pagination" in res.json()):
|
||||
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
|
||||
|
|
@ -5,6 +5,8 @@ from jsonschema import ValidationError
|
|||
class KratosError(Exception):
|
||||
pass
|
||||
|
||||
class AuthentikError(Exception):
|
||||
pass
|
||||
|
||||
class HydraError(Exception):
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import requests
|
|||
from config import *
|
||||
from .error_handler import KratosError
|
||||
|
||||
|
||||
class KratosApi:
|
||||
@staticmethod
|
||||
def __handleError(res):
|
||||
|
|
|
|||
51
backend/helpers/lit_oauth.py
Normal file
51
backend/helpers/lit_oauth.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
6
backend/web/static/base.js
vendored
6
backend/web/static/base.js
vendored
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue