Skip to content
Snippets Groups Projects
Commit 864a4636 authored by Václav Bartoš's avatar Václav Bartoš
Browse files

possibility to add and delete users

parent 0376f33b
Branches
Tags
No related merge requests found
...@@ -4,6 +4,7 @@ from datetime import datetime ...@@ -4,6 +4,7 @@ from datetime import datetime
import os.path import os.path
import re import re
import subprocess import subprocess
from typing import List,Dict
from flask import Flask, render_template, request, make_response, redirect, flash from flask import Flask, render_template, request, make_response, redirect, flash
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
...@@ -25,13 +26,14 @@ KEYCLOAK_ADMIN_PASSWORD_FILE = os.path.join(SOCTOOLS_BASE, "secrets/passwords/ke ...@@ -25,13 +26,14 @@ KEYCLOAK_ADMIN_PASSWORD_FILE = os.path.join(SOCTOOLS_BASE, "secrets/passwords/ke
@app.before_first_request @app.before_first_request
def load_config(): def load_config():
"""Load various variables, api keys, etc. and set configuration parameters""" """Load various variables, api keys, etc. and set configuration parameters"""
global SOCTOOLSPROXY, KEYCLOAK_BASE_URL, KEYCLOAK_ADMIN_PASSWORD global SOCTOOLSPROXY, KEYCLOAK_BASE_URL, KEYCLOAK_USERS_URL, KEYCLOAK_ADMIN_PASSWORD
variables = yaml.safe_load(open(VARIABLES_FILE, "r")) variables = yaml.safe_load(open(VARIABLES_FILE, "r"))
# Get FQDN of the main server # Get FQDN of the main server
SOCTOOLSPROXY = variables["soctoolsproxy"] SOCTOOLSPROXY = variables["soctoolsproxy"]
assert re.match('[a-zA-Z0-9.-]+', SOCTOOLSPROXY), f"ERROR: The 'soctoolsproxy' variable loaded from '{VARIABLES_FILE}' is not a valid domain name." assert re.match('[a-zA-Z0-9.-]+', SOCTOOLSPROXY), f"ERROR: The 'soctoolsproxy' variable loaded from '{VARIABLES_FILE}' is not a valid domain name."
# Set base URL to Keycloak # Set base URL to Keycloak
KEYCLOAK_BASE_URL = f"https://{SOCTOOLSPROXY}:12443" KEYCLOAK_BASE_URL = f"https://{SOCTOOLSPROXY}:12443"
KEYCLOAK_USERS_URL = KEYCLOAK_BASE_URL + "/auth/admin/realms/SOCTOOLS1/users"
# Load API key for Keycloak # Load API key for Keycloak
KEYCLOAK_ADMIN_PASSWORD = open(KEYCLOAK_ADMIN_PASSWORD_FILE, "r").read(100).strip() # read max 100 B, the key should never be so long KEYCLOAK_ADMIN_PASSWORD = open(KEYCLOAK_ADMIN_PASSWORD_FILE, "r").read(100).strip() # read max 100 B, the key should never be so long
print(f"Config loaded:\nSOCTOOLSPROXY={SOCTOOLSPROXY}\nKEYCLOAK_BASE_URL={KEYCLOAK_BASE_URL}\n" print(f"Config loaded:\nSOCTOOLSPROXY={SOCTOOLSPROXY}\nKEYCLOAK_BASE_URL={KEYCLOAK_BASE_URL}\n"
...@@ -39,7 +41,7 @@ def load_config(): ...@@ -39,7 +41,7 @@ def load_config():
# *** Custom Jinja filters *** # *** Custom Jinja filters ***
def ts_to_str(ts): def ts_to_str(ts: float) -> str:
return datetime.utcfromtimestamp(int(ts)).isoformat(sep=" ") # TODO Do Keycloak really use UTC timestamps? return datetime.utcfromtimestamp(int(ts)).isoformat(sep=" ") # TODO Do Keycloak really use UTC timestamps?
app.jinja_env.filters["ts_to_str"] = ts_to_str app.jinja_env.filters["ts_to_str"] = ts_to_str
...@@ -47,8 +49,15 @@ app.jinja_env.filters["ts_to_str"] = ts_to_str ...@@ -47,8 +49,15 @@ app.jinja_env.filters["ts_to_str"] = ts_to_str
# *** Functions to call other APIs *** # *** Functions to call other APIs ***
def get_token(): class KeycloakError(Exception):
"""Get admin's OIDC token from Keycloak - needed to perform any administrative API call""" pass
def kc_get_token() -> str:
"""
Get admin's OIDC token from Keycloak - needed to perform any administrative API call
Return the token or raise KeycloakError
"""
url = KEYCLOAK_BASE_URL + "/auth/realms/master/protocol/openid-connect/token" url = KEYCLOAK_BASE_URL + "/auth/realms/master/protocol/openid-connect/token"
data = { data = {
"client_id": "admin-cli", "client_id": "admin-cli",
...@@ -59,32 +68,70 @@ def get_token(): ...@@ -59,32 +68,70 @@ def get_token():
try: try:
resp = requests.post(url, data, verify=CA_CERT_FILE) resp = requests.post(url, data, verify=CA_CERT_FILE)
if resp.status_code != 200: if resp.status_code != 200:
flash(f"ERROR: Can't get token for API access: ({resp.status_code}) {resp.text[:200]}", "error") raise KeycloakError(f"Can't get OIDC token for API access: ({resp.status_code}) {resp.text[:200]}")
return None
return str(resp.json()['access_token']) return str(resp.json()['access_token'])
except Exception as e: except Exception as e:
flash(f"ERROR: Can't get token for API access: {type(e).__name__}: {e}", "error") raise KeycloakError(f"Can't get OIDC token for API access: {type(e).__name__}: {e}")
return None
def kc_get_users() -> List[Dict]:
def get_users(): """
# Get list of users from Keycloak Get list of users from Keycloak
url = KEYCLOAK_BASE_URL + "/auth/admin/realms/SOCTOOLS1/users"
token = get_token() :return List of dicts, one per user, with keys matching the Keycloak user representation:
if token is None: https://www.keycloak.org/docs-api/12.0/rest-api/#_userrepresentation
return [] # can't get token, error message is already flashed by get_token function :raise KeycloakError
resp = requests.get(url, headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT_FILE) """
token = kc_get_token()
resp = requests.get(KEYCLOAK_USERS_URL, headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT_FILE)
if not resp.ok: if not resp.ok:
flash(f"ERROR: Can't get list of users: ({resp.status_code}) {resp.text[:200]}", "error") raise KeycloakError("Can't get list of users: ({resp.status_code}) {resp.text[:200]}")
return []
try: try:
users = resp.json() users = resp.json()
assert isinstance(users, list) and all(isinstance(o, dict) for o in users), "" assert isinstance(users, list) and all(isinstance(o, dict) for o in users), ""
except (ValueError, AssertionError): except (ValueError, AssertionError):
flash(f"ERROR: Can't get list of users: Unexpected content of response from Keycloak", "error") raise KeycloakError(f"Can't get list of users: Unexpected content of response from Keycloak")
return []
return users return users
def kc_add_user(username: str, firstname: str, lastname: str, cn: str, email: str) -> None:
"""Add a new user to Keycloak
:return None
:raises KeycloakError
"""
token = kc_get_token()
user_data = {
"username": username,
"firstName": firstname,
"lastName": lastname,
"email": email,
"attributes": {
"CN": [cn],
"DN": [f"CN={cn}"]
},
"enabled": True # user must be explicitly enabled, default is False
}
resp = requests.post(KEYCLOAK_USERS_URL, json=user_data,
headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT_FILE)
if not resp.ok:
raise KeycloakError(f"({resp.status_code}) {resp.text[:200]}")
def kc_delete_user(userid: str) -> None:
"""Delete a user from Keycloak
:param userid: Keycloak user ID (not username)
:return None
:raise KeycloakError
"""
token = kc_get_token()
url = KEYCLOAK_USERS_URL + "/" + userid
resp = requests.delete(url, headers={'Authorization': 'Bearer ' + token}, verify=CA_CERT_FILE)
if not resp.ok:
raise KeycloakError(f"({resp.status_code}) {resp.text[:200]}")
# *** Flask endpoints and forms *** # *** Flask endpoints and forms ***
class AddUserForm(FlaskForm): class AddUserForm(FlaskForm):
...@@ -98,25 +145,39 @@ class AddUserForm(FlaskForm): ...@@ -98,25 +145,39 @@ class AddUserForm(FlaskForm):
@app.route("/", methods=["GET", "POST"]) @app.route("/", methods=["GET", "POST"])
def main(): def main():
# Load existing users from Keycloak
users = get_users()
#print(users)
# Add user form # Add user form
form_add_user = AddUserForm() form_add_user = AddUserForm()
if form_add_user.validate_on_submit(): if form_add_user.validate_on_submit():
# TODO check that username doesn't exist, yet (and check validity, i.e. special characters etc.) # TODO check that username doesn't exist, yet (and check validity, i.e. special characters etc.)
# TODO add user # Add user
result = subprocess.run(["echo", "test"]) try:
if result.returncode == 0: kc_add_user(form_add_user.username.data, form_add_user.firstname.data, form_add_user.lastname.data,
form_add_user.cn.data, form_add_user.email.data)
flash(f'User "{form_add_user.username.data}" successfully created.', "success") flash(f'User "{form_add_user.username.data}" successfully created.', "success")
else: return redirect("/") # Force new load of the page using GET, so page refresh doesn't trigger new POST.
flash(f'Error when creating user: {result.stderr}', "error") except KeycloakError as e:
flash(f'Error when creating user: {e}', "error")
# Load existing users from Keycloak
try:
users = kc_get_users()
except KeycloakError as e:
flash(f"ERROR: {e}", "error")
users = []
#print(users)
return render_template("main.html", **locals()) return render_template("main.html", **locals())
# TODO AJAX endpoint to delete user @app.route("/delete_user/<userid>")
def delete_user(userid: str):
"""Delete user given by userid and redirect back to main page"""
try:
kc_delete_user(userid)
flash(f'User successfully deleted.', "success")
except KeycloakError as e:
flash(f'Error when deleting user: {e}', "error")
return redirect("/")
# TODO edit user? User detail page? # TODO edit user? User detail page?
......
...@@ -31,8 +31,10 @@ ...@@ -31,8 +31,10 @@
<td>{{ user.attributes.CN[0] }}</td> <td>{{ user.attributes.CN[0] }}</td>
<td>{{ user.attributes.DN[0] }}</td> <td>{{ user.attributes.DN[0] }}</td>
<td>{{ (user.createdTimestamp/1000)|ts_to_str }}</td> <td>{{ (user.createdTimestamp/1000)|ts_to_str }}</td>
<td>...</td> <td><a href="{{ url_for('delete_user', userid=user.id) }}" title="Delete user"
onclick="return confirm('Are you sure you want to permanently delete user account &quot;{{user.username}}&quot; ({{user.attributes.CN[0]}}, {{user.email}})?')">&#128465;</a></td>
</tr> </tr>
{#<tr><td colspan=8>{{ user }}</td></tr>#}
{% endfor %} {% endfor %}
</table> </table>
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment