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