From c552f7f7302d26341e8c1431201cac51d7e74b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Barto=C5=A1?= <bartos@cesnet.cz> Date: Fri, 1 Apr 2022 11:02:44 +0200 Subject: [PATCH] load list of users from keycloak (configuration needed) + added requirements.txt --- main.py | 83 ++++++++++++++++++++++++++++++++++----------- requirements.txt | 6 ++++ templates/main.html | 15 ++++---- 3 files changed, 77 insertions(+), 27 deletions(-) create mode 100644 requirements.txt diff --git a/main.py b/main.py index bd025aa..6e5a121 100644 --- a/main.py +++ b/main.py @@ -1,45 +1,88 @@ # Example of minimal working WSGI script from flask import Flask, render_template, request, make_response, redirect, flash - from flask_wtf import FlaskForm from wtforms import StringField from wtforms.validators import DataRequired, Email +import requests +from datetime import datetime + import subprocess app = Flask(__name__) app.secret_key = "ASDF1234 - CHANGE ME!" +# *** Configuration *** +# TODO get this from config/environment +CA_CERT = "" # path to secrets/CA/ca.crt +KEYCLOAK_BASE_URL = "" # https://{{soctoolsproxy}}:12443 +KEYCLOAK_ADMIN_PASSWORD = "" # take from secrets/passwords/keykloak_admin (Note: should be keycloak, not keykloak) + +# *** Custom Jinja filters *** +def ts_to_str(ts): + return datetime.utcfromtimestamp(int(ts)).isoformat(sep=" ") # TODO Do Keycloak really use UTC timestamps? + +app.jinja_env.filters["ts_to_str"] = ts_to_str + + +# *** Functions to call other APIs *** + +def get_token(): + """Get admin's OIDC token from Keycloak - needed to perform any administrative API call""" + url = KEYCLOAK_BASE_URL + "/auth/realms/master/protocol/openid-connect/token" + data = { + "client_id": "admin-cli", + "username": "admin", + "password": KEYCLOAK_ADMIN_PASSWORD, + "grant_type": "password" + } + try: + resp = requests.post(url, data, verify=CA_CERT) + if resp.status_code != 200: + flash(f"ERROR: Can't get token for API access: ({resp.status_code}) {resp.text[:200]}", "error") + return None + return str(resp.json()['access_token']) + except Exception as e: + flash(f"ERROR: Can't get token for API access: {type(e).__name__}: {e}", "error") + return None + +def get_users(): + # Get list of users from Keycloak + url = KEYCLOAK_BASE_URL + "/auth/admin/realms/SOCTOOLS1/users" + token = get_token() + if token is None: + return [] # can't get token, error message is already flashed by get_token function + resp = requests.get(url, headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT) + if not resp.ok: + flash(f"ERROR: Can't get list of users: ({resp.status_code}) {resp.text[:200]}", "error") + return [] + try: + users = resp.json() + assert isinstance(users, list) and all(isinstance(o, dict) for o in users), "" + except (ValueError, AssertionError): + flash(f"ERROR: Can't get list of users: Unexpected content of response from Keycloak", "error") + return [] + return users + + +# *** Flask endpoints and forms *** class AddUserForm(FlaskForm): username = StringField("Username", validators=[DataRequired()]) cn = StringField("Common name (CN)", validators=[DataRequired()]) firstname = StringField("First name", validators=[]) lastname = StringField("Last name", validators=[]) - # TODO what about CN/DN - construct from first+last name or allow to redefine? email = StringField("Email", validators=[DataRequired(), Email()]) + # DN is constructed automatically from CN @app.route("/", methods=["GET", "POST"]) def main(): - # TODO Load existing users (from where?) - users = [{ - "firstname": "User1", - "lastname": "SOC", - "username": "user1", - "email": "user1@example.org", - "DN": "CN=User1Soctools", - "CN": "User1Soctools", - },{ - "firstname": "User2", - "lastname": "SOC", - "username": "user2", - "email": "user2@example.org", - "DN": "CN=User2Soctools", - "CN": "User2Soctools", - }] - - # Add user + # Load existing users from Keycloak + users = get_users() + #print(users) + + # Add user form form_add_user = AddUserForm() if form_add_user.validate_on_submit(): # TODO check that username doesn't exist, yet (and check validity, i.e. special characters etc.) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..92710e6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +flask~=2.1.0 +flask_wtf~=1.0.0 +wtforms~=3.0.1 +email-validator~=1.1.3 +requests~=2.27.1 +jinja2~=3.1.1 \ No newline at end of file diff --git a/templates/main.html b/templates/main.html index edf70bf..bf3b73e 100644 --- a/templates/main.html +++ b/templates/main.html @@ -20,21 +20,22 @@ <h1>SOCtools - User management</h1> <table> -<tr><th>Username</th><th>First name</th><th>Last name</th><th>CN</th><th>email</th><th></th> +<tr><th>Username</th><th>First name</th><th>Last name</th><th>email</th><th>CN</th><th>DN</th><th>Time created</th><th></th> + {% for user in users %} <tr> <td>{{ user.username }}</td> -<td>{{ user.firstname }}</td> -<td>{{ user.lastname }}</td> -<td>{{ user.CN }}</td> +<td>{{ user.firstName }}</td> +<td>{{ user.lastName }}</td> <td>{{ user.email }}</td> -<td>... {#TODO actions#}</td> +<td>{{ user.attributes.CN[0] }}</td> +<td>{{ user.attributes.DN[0] }}</td> +<td>{{ (user.createdTimestamp/1000)|ts_to_str }}</td> +<td>...</td> </tr> {% endfor %} </table> -<p></p> - <h2>Add new user</h2> <form action="{{ url_for("main") }}" method="POST"> {% if form_add_user.errors %} -- GitLab