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:
philipp 2022-11-08 16:36:16 +01:00
parent 8d760e588f
commit 44e4e4eb42
35 changed files with 367 additions and 133 deletions

View file

@ -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"

View file

@ -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

View file

@ -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": "",
}

View file

@ -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
}
}
)

View 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

View 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,
}

View file

@ -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"])

View file

@ -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")

View file

@ -2,3 +2,4 @@ from .kratos_api import *
from .error_handler import *
from .hydra_oauth import *
from .kratos_user import *
from .lit_oauth import *

View file

@ -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:

View 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

View file

@ -5,6 +5,8 @@ from jsonschema import ValidationError
class KratosError(Exception):
pass
class AuthentikError(Exception):
pass
class HydraError(Exception):
pass

View file

@ -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
)

View file

@ -4,7 +4,6 @@ import requests
from config import *
from .error_handler import KratosError
class KratosApi:
@staticmethod
def __handleError(res):

View 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)

View file

@ -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

View file

@ -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'];
}
}