Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • xeruf/dashboard
  • stackspin/dashboard
2 results
Show changes
Showing
with 2215 additions and 753 deletions
"""
List of functions to get data from Flux Kustomizations and Helmreleases
"""
import crypt
import functools
import secrets
import string
import threading
import jinja2
import yaml
from kubernetes import client, config
from kubernetes import client, config, watch
from kubernetes.config.incluster_config import InClusterConfigLoader
from kubernetes.client import api_client
from kubernetes.client.exceptions import ApiException
from kubernetes.utils import create_from_yaml
from kubernetes.utils.create_from_yaml import FailToCreateError
from flask import current_app
from config import LOAD_INCLUSTER_CONFIG
from config import KUBECONFIG, LOAD_INCLUSTER_CONFIG, TELEPRESENCE
# Load the kube config once
#
# By default this loads whatever we define in the `KUBECONFIG` env variable,
# otherwise loads the config from default locations, similar to what kubectl
# does.
if LOAD_INCLUSTER_CONFIG:
if TELEPRESENCE:
print(f"token_filename: {KUBECONFIG}/token")
import os
if os.path.isfile(f"{KUBECONFIG}/token"):
print("token_filename exists")
else:
print("token_filename does not exist")
InClusterConfigLoader(
token_filename=f"{KUBECONFIG}/token",
cert_filename=f"{KUBECONFIG}/ca.crt"
).load_and_set()
elif LOAD_INCLUSTER_CONFIG:
config.load_incluster_config()
else:
config.load_kube_config()
......@@ -53,7 +66,7 @@ def create_variables_secret(app_slug, variables_filepath):
elif current_secret_data.keys() != new_secret_dict["data"].keys():
# Update current secret with new keys
update_secret = True
current_app.logger.info(
current_app.logger.debug(
f"Secret {secret_name} in namespace {secret_namespace}"
" already exists. Merging..."
)
......@@ -61,12 +74,12 @@ def create_variables_secret(app_slug, variables_filepath):
new_secret_dict["data"] |= current_secret_data
else:
# Do Nothing
current_app.logger.info(
current_app.logger.debug(
f"Secret {secret_name} in namespace {secret_namespace}"
" is already in a good state, doing nothing."
)
return True
current_app.logger.info(
current_app.logger.debug(
f"Storing secret {secret_name} in namespace"
f" {secret_namespace} in cluster."
)
......@@ -170,9 +183,9 @@ def store_kubernetes_secret(secret_dict, namespace, update=False):
namespace=namespace
)
except FailToCreateError as ex:
current_app.logger.info(f"Secret not created because of exception {ex}")
current_app.logger.warning(f"Secret not created because of exception {ex}")
raise ex
current_app.logger.info(f"Secret {verb} with api response: {api_response}")
current_app.logger.debug(f"Secret {verb} with api response: {api_response}")
def store_kustomization(kustomization_template_filepath, app_slug):
......@@ -198,7 +211,7 @@ def store_kustomization(kustomization_template_filepath, app_slug):
plural="kustomizations",
body=kustomization_dict)
except FailToCreateError as ex:
current_app.logger.info(
current_app.logger.warning(
f"Could not create {app_slug} Kustomization because of exception {ex}")
raise ex
current_app.logger.debug(f"Kustomization created with api response: {api_response}")
......@@ -230,7 +243,7 @@ def delete_kustomization(kustomization_name):
name=kustomization_name,
body=body)
except ApiException as ex:
current_app.logger.info(
current_app.logger.warning(
f"Could not delete {kustomization_name} Kustomization because of exception {ex}")
raise ex
current_app.logger.debug(f"Kustomization deleted with api response: {api_response}")
......@@ -295,20 +308,6 @@ def generate_password(length):
return password
def gen_htpasswd(user, password):
"""
Generate htpasswd entry for user with password.
:param user: Username used in the htpasswd entry
:type user: string
:param password: Password for the user, will get encrypted.
:type password: string
:return: htpassword line entry
:rtype: string
"""
return f"{user}:{crypt.crypt(password, crypt.mksalt(crypt.METHOD_SHA512))}"
def get_all_kustomizations(namespace='flux-system'):
"""
Returns all flux kustomizations in a namespace.
......@@ -382,3 +381,111 @@ def get_kustomization(name, namespace='flux-system'):
# Raise all non-404 errors
raise error
return resource
def get_gitrepo(name, namespace='flux-system'):
"""
Returns all info on a Flux GitRepo.
:param name: Name of the gitrepo
:type name: string
:param namespace: Namespace of the gitrepo
:type namespace: string
:return: gitrepo as returned by the API
:rtype: dict
"""
api = client.CustomObjectsApi()
try:
resource = api.get_namespaced_custom_object(
group="source.toolkit.fluxcd.io",
version="v1beta2",
name=name,
namespace=namespace,
plural="gitrepositories",
)
except client.exceptions.ApiException as error:
if error.status == 404:
return None
# Raise all non-404 errors
raise error
return resource
def debounce(timeout: float):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
wrapper.func.cancel()
wrapper.func = threading.Timer(timeout, func, args, kwargs)
wrapper.func.start()
wrapper.func = threading.Timer(timeout, lambda: None)
return wrapper
return decorator
def watch_dashboard_config(app, reload):
# Number of seconds to wait before reloading in case more secrets show up.
# In particular this prevents us from reloading once for every
# secret that exists at startup in succession.
debounce_timeout = 1
@debounce(debounce_timeout)
def debounced_reload():
reload()
w = watch.Watch()
api_instance = client.CoreV1Api(api_client.ApiClient())
def watch_scim_secrets():
with app.app_context():
for event in w.stream(
api_instance.list_namespaced_secret,
'flux-system',
label_selector="stackspin.net/scim-config=1",
watch=True
):
current_app.logger.debug(f"{event['type']} SCIM config secret: {event['object'].metadata.name}")
debounced_reload()
threading.Thread(target=watch_scim_secrets).start()
def watch_dashboard_configmaps():
with app.app_context():
for event in w.stream(
api_instance.list_namespaced_config_map,
'flux-system',
label_selector="stackspin.net/dashboard-config=1",
watch=True
):
current_app.logger.debug(f"{event['type']} dashboard config configmap: {event['object'].metadata.name}")
debounced_reload()
threading.Thread(target=watch_dashboard_configmaps).start()
def check_condition(status):
"""
Returns a tuple that has true/false for readiness and a message
Ready, in this case means that the condition's type == "Ready" and its
status == "True". If the condition type "Ready" does not occur, the
status is interpreted as not ready.
The message that is returned is the message that comes with the
condition with type "Ready"
:param status: Kubernetes resource's "status" object.
:type status: dict
"""
if status["observedGeneration"] == -1:
return False, "Kustomization is not yet seen by controller"
for condition in status["conditions"]:
if condition["type"] == "Ready":
return condition["status"] == "True", condition["message"]
return False, "Condition with type 'Ready' not found"
def wait_kustomization_ready(app):
w = watch.Watch()
api_instance = client.CustomObjectsApi()
for event in w.stream(api_instance.list_namespaced_custom_object, 'kustomize.toolkit.fluxcd.io', 'v1', 'flux-system', 'kustomizations'):
ks = event['object']
if ks['metadata']['name'] != app.slug:
# We're currently only interested in the `app` app.
continue
ks_ready, ks_message = check_condition(ks['status'])
if not ks_ready:
# There is some data on the app kustomization, but it's not ready
# yet.
continue
print(f"Kustomization {app.slug} is now ready.")
return
from datetime import datetime
import json.decoder
import logging
import ory_kratos_client
from ory_kratos_client.api import identity_api
import ory_kratos_client.exceptions
import requests
from sqlalchemy import exc, select
from sqlalchemy.sql.expression import literal
from areas.apps.models import App, AppRole, ProvisionStatus, ScimAttribute
from areas.roles.models import Role
import config
from database import db
from helpers.kratos_user import KratosUser
from helpers.exceptions import BackendError
class ProvisionError(Exception):
pass
class User:
def __init__(self, kratos_id, scim_id, username, displayName):
self.kratos_id = kratos_id
self.scim_id = scim_id
self.username = username
self.displayName = displayName
def ref(self, base_url):
return f"{base_url}/Users/{self.scim_id}"
class Group:
def __init__(self, scim_id, displayName, members):
self.scim_id = scim_id
self.displayName = displayName
self.members = members
self.modified = False
def add_member(self, user):
if user.scim_id not in self.members:
logging.debug(f"Adding user to dict: {user.displayName} ({user.scim_id})")
self.members[user.scim_id] = user
self.modified = True
def remove_member(self, user):
if user.scim_id in self.members:
logging.debug(f"Found user to remove from dict: {user.displayName} ({user.scim_id})")
del self.members[user.scim_id]
self.modified = True
def debug(self):
logging.debug(f"Group {self.displayName} ({self.scim_id})")
for _, member in self.members.items():
logging.debug(f" with user {member.displayName} ({member.scim_id})")
class ScimUser:
"""
A lower-level helper class that represents the SCIM representation of a
user, and knows how to do the actual SCIM POST/PUT calls.
"""
__allowed = (
'app',
'kratos_id',
'scim_id',
'active',
'email',
'username',
'display_name',
'include_name',
)
def __init__(self, **kwargs):
self.include_name = False
self.active = True
for k, v in kwargs.items():
assert(k in self.__class__.__allowed)
setattr(self, k, v)
# According to the SCIM RFC, the `userName` attribute is required, and
# it should be how to user identifies to the system.
# Zulip does not read the `emails` property, instead getting the
# email from the `userName` property.
if hasattr(self, 'email') and getattr(self, 'username', None) is None:
self.username = self.email
def _provision_data(self):
data = {
'schemas': ['urn:ietf:params:scim:schemas:core:2.0:User'],
'externalId': self.kratos_id,
'active': self.active,
'userName': self.username,
}
if hasattr(self, 'email'):
data['emails'] = [{
'value': self.email,
'primary': True
}]
if self.include_name:
data['displayName'] = self.display_name
data['name'] = {
'formatted': self.display_name,
}
if hasattr(self, 'role'):
data['role'] = self.role
return data
def _scim_headers(self):
return {
'Authorization': 'Bearer ' + self.app.scim_token
}
def create(self):
data = self._provision_data()
url = f"{self.app.scim_url}/Users"
response = requests.post(url, headers=self._scim_headers(), json=data)
logging.debug(f"Post SCIM user: {url} with data: {data} getting status: {response.status_code}")
try:
response_json = response.json()
except json.decoder.JSONDecodeError as e:
logging.info("SCIM result was not valid json:")
logging.info(response.content)
raise ProvisionError("App returned non-json data in SCIM user post.")
return response_json
def update(self):
data = self._provision_data()
url = f"{self.app.scim_url}/Users/{self.scim_id}"
response = requests.put(url, headers=self._scim_headers(), json=data)
logging.debug(f"Put SCIM user: {url} with data: {data} getting status: {response.status_code}")
try:
response_json = response.json()
except json.decoder.JSONDecodeError as e:
logging.info("SCIM result was not valid json:")
logging.info(response.content)
raise ProvisionError("App returned non-json data in SCIM user put.")
return response_json
# Read from the database which users need to be provisioned on which apps, and
# do the corresponding SCIM calls to those apps to do the actual provisioning.
class Provision:
def __init__(self):
# Set up kratos API client.
kratos_admin_api_configuration = ory_kratos_client.Configuration(host=config.KRATOS_ADMIN_URL)
kratos_admin_client = ory_kratos_client.ApiClient(kratos_admin_api_configuration)
self.kratos_identity_api = identity_api.IdentityApi(kratos_admin_client)
# We don't do this in init, because at the moment this object is created we
# might not have performed the database migration yet that creates the scim
# config columns. This function will be called at the start of `reconcile`
# to make sure we have the most recent list of SCIM-enabled apps.
def _load_config(self):
logging.info("Loading SCIM configuration from database for all apps.")
database_apps = App.query.filter(App.scim_url != None).all()
enabled = False
apps = {}
for app in database_apps:
if app.scim_token is None:
logging.warn(f"scim_token is not set, so disabling SCIM for {app.slug}")
continue
apps[app.id] = app
logging.info(f"Enabling SCIM for {app.slug}, with URL {app.scim_url}")
enabled = True
self.enabled = enabled
self.scim_apps = apps
def app_supported(self, app):
return (app.id in self.scim_apps)
def reconcile(self):
logging.info("User provisioning run.")
self._load_config()
# Collect existing in-app users and groups in advance so we can compare to that
# efficiently when we go over the dashboard users.
# TODO: only do this for apps that we have any use for.
existing_users = {}
existing_groups = {}
admin_group = {}
for app_id, app in self.scim_apps.items():
existing_users[app_id] = self._get_existing_users(app)
for userId, u in existing_users[app_id].items():
logging.debug(f"Existing user in {app.slug}: {u.displayName} ({userId})")
if app.scim_group_support:
existing_groups[app_id] = self._get_existing_groups(app)
for _, g in existing_groups[app_id].items():
g.debug()
# We will modify this group in-memory over the course of the
# user reconciliation. After we have reconciled all users, we
# will update the group in a single operation. We could also
# use PATCH operations instead to add single users as we go,
# but the Nextcloud SCIM app does not support that.
admin_group[app_id] = existing_groups[app_id].get('admin')
if admin_group[app_id] is None:
raise ProvisionError("Admin group for {app.slug} could not be found, aborting.")
# TODO: we later need to retry some failed ones as well: at least Error
# in case it was a temporary error, and NotSupported in case the app
# supports SCIM now whereas it did not before. Maybe do that only for
# regular full (re)provision runs, not for ones requested because of
# changes.
app_roles = db.session.query(AppRole).filter(
AppRole.provision_status.in_((ProvisionStatus.SyncNeeded, ProvisionStatus.ToDelete))
)
for app_role in app_roles:
logging.debug(f"found app_role: {app_role}")
# Check if this app supports SCIM at all and is configured for it.
app_role.last_provision_attempt = datetime.now()
app = app_role.app
if not self.app_supported(app):
app_role.provision_status = ProvisionStatus.NotSupported
app_role.last_provision_message = f"App does not support automatic user provisioning."
db.session.commit()
continue
# Look up any existing in-app user with this ID from the
# pre-fetched list we got via SCIM.
existing_user = existing_users[app.id].get(app_role.user_id)
if existing_user is not None:
logging.debug(f"User {app_role.user_id} already exists in the app {app.slug}.")
if app_role.role_id == Role.NO_ACCESS_ROLE_ID:
if existing_user is None:
logging.debug(f"User without access does not exist yet in {app.slug}, so nothing to do.")
# We have not provisioned this user in this app yet, so we
# don't have to do anything at this point.
app_role.provision_status = ProvisionStatus.Provisioned
app_role.last_provision_message = "Provisioning not required for user without access."
db.session.commit()
continue
else:
logging.debug(f"User without access exists in the app {app.slug}; we continue so we can disable the user in the app.")
try:
self._provision_user(app_role, app, existing_user, admin_group.get(app.id))
except ProvisionError as ex:
app_role.provision_status = ProvisionStatus.Error
app_role.last_provision_message = str(ex)
db.session.commit()
for app_id, app in self.scim_apps.items():
if app.scim_group_support:
if admin_group[app_id].modified:
logging.debug(f"Admin group for {app.slug} was modified, so updating it via SCIM.")
admin_group[app_id].debug()
self._provision_group(admin_group[app_id], app)
else:
logging.debug(f"Admin group for {app.slug} was not modified.")
# Provision the user via SCIM PUT or POST, based on the user and role
# information in `app_role`, and on the existing user object in the app
# `existing_user` (previously obtained via SCIM GET). Also update the
# `admin_group` object so we may later add or remove the user to/from the
# admin group.
def _provision_user(self, app_role, app, existing_user, admin_group):
logging.debug(f"Reconciling user {app_role.user_id}")
app = self.scim_apps[app.id]
scim_headers = {
'Authorization': 'Bearer ' + app.scim_token
}
if app_role.provision_status == ProvisionStatus.ToDelete:
if existing_user is None:
db.session.delete(app_role)
db.session.commit()
else:
logging.info(f"Deleting user {app_role.user_id} from {app.slug}")
url = f"{app.scim_url}/Users/{existing_user.scim_id}"
response = requests.delete(url, headers=scim_headers)
logging.debug(f"SCIM http status: {response.status_code}")
if response.status_code == 204:
db.session.delete(app_role)
db.session.commit()
elif response.status_code == 400:
error_json = response.json()
try:
not_supported = error_json['detail'].startswith('DELETE operation not supported.')
except KeyError:
not_supported = False
if not_supported:
# Disable the app account as we can't delete it through
# SCIM.
logging.debug("App does not support deleting account, so disabling instead.")
ScimUser(
app=app,
kratos_id=app_role.user_id,
scim_id=existing_user.scim_id,
username=existing_user.username,
active=False,
).update()
db.session.delete(app_role)
db.session.commit()
else:
logging.info(f"Error returned by SCIM deletion: {response.content}")
raise ProvisionError("App cannot delete user via SCIM.")
else:
logging.info(f"Error returned by SCIM deletion: {response.content}")
raise ProvisionError("App cannot delete user via SCIM.")
return
# Get the related user object
logging.debug(f"Getting user data from Kratos.")
try:
kratos_user = KratosUser(self.kratos_identity_api, app_role.user_id)
except BackendError as e:
if isinstance(e.upstream_exception, ory_kratos_client.exceptions.NotFoundException):
app_role.provision_status = ProvisionStatus.Orphaned
app_role.last_provision_message = "Cannot provision because this Stackspin user does not exist (anymore)."
db.session.commit()
return
else:
raise
# Existing users that should not have access are disabled using the
# SCIM attribute `active`.
if app_role.role_id == Role.NO_ACCESS_ROLE_ID:
active = False
else:
active = True
logging.debug(f"Active user: {active}")
scim_object = ScimUser(
app=app,
kratos_id=app_role.user_id,
active=active,
email=kratos_user.email,
# This is what at least Zulip needs. For Nextcloud we override this later on.
username=kratos_user.email,
)
# Decide whether to include the displayName in the SCIM data. Usually
# we want that, but if this is an existing user, and we think the
# displayName as stored in Stackspin has not changed since the last
# provisioning run, then we do not set it to prevent overwriting a
# displayName the user may have set inside the app, bypassing
# Stackspin.
name_changed = ScimAttribute.query.filter_by(
user_id=app_role.user_id,
app_id=app_role.app_id,
attribute='name',
).first()
logging.debug(f"Name changed: {name_changed}")
include_name = existing_user is None or name_changed is not None
if include_name:
scim_object.display_name = kratos_user.name
scim_object.include_name = True
logging.debug(f"Setting name in provisioning request.")
# Now clear the `name_changed` attribute so we don't set the name
# again -- until it's changed again in Stackspin.
Provision.done_attribute(
attribute='name',
user_id=app_role.user_id,
app_id=app_role.app_id,
)
# Make some app-specific additions and modifications to the SCIM data.
if app.slug == 'nextcloud':
# Sadly Nextcloud doesn't allow changing the userName, so we set it
# to something unique and stable.
# https://github.com/nextcloud/server/issues/5488
# We add the `stackspin-` prefix to make this compatible with the
# username generated by the sociallogin (SSO) Nextcloud app.
scim_object.username = f"stackspin-{app_role.user_id}"
if app.slug == 'zulip':
# Zulip does not accept an empty formatted name.
if include_name and (kratos_user.name is None or kratos_user.name == ''):
scim_object.display_name = "name not set"
# Zulip doesn't support SCIM user groups, but we can set the user
# role as a field on the user object.
if app_role.role_id == Role.ADMIN_ROLE_ID:
scim_object.role = 'owner'
else:
scim_object.role = 'member'
# Now format the URL and make the SCIM request.
if existing_user is None:
scim_result = scim_object.create()
else:
scim_object.scim_id = existing_user.scim_id
scim_result = scim_object.update()
logging.debug(f"got: {scim_result}")
if existing_user is None:
# Because this is a new user for the app, we should read off its
# SCIM ID and store that in the Stackspin database.
app_role.scim_id = scim_result['id']
db.session.commit()
user = User(app_role.user_id, scim_result['id'], scim_result['userName'], kratos_user.name)
if app.scim_group_support:
if app_role.role_id == Role.ADMIN_ROLE_ID:
logging.debug(f"Adding user to admin group: {user.displayName} ({user.kratos_id})")
admin_group.add_member(user)
else:
logging.debug(f"Removing user from admin group: {user.displayName} ({user.kratos_id})")
admin_group.remove_member(user)
logging.debug("After adding/removing user:")
admin_group.debug()
app_role.provision_status = ProvisionStatus.Provisioned
app_role.last_provision_message = "User successfully provisioned."
db.session.commit()
return
def _scim_list_users(self, app):
logging.debug(f"Getting list of current users from {app.slug} via SCIM.")
# SCIM prescribes a 1-based index.
startIndex = 1
# Get this many items per request. The application might further reduce
# this number so we can't count on it.
count = 100
users = []
# Track how many users we've received thus far so we know when to stop.
running_total = 0
while True:
url = f"{app.scim_url}/Users?count={count}&startIndex={startIndex}"
scim_headers = {
'Authorization': 'Bearer ' + app.scim_token
}
response = requests.get(url, headers=scim_headers)
logging.debug(f"SCIM http status: {response.status_code}")
try:
response_json = response.json()
except json.decoder.JSONDecodeError as e:
logging.info("SCIM result was not json")
logging.info(response.content)
raise ProvisionError(f"Failed to get existing users from {app.slug}")
new_users = response_json['Resources']
users = users + new_users
added = len(new_users)
running_total = running_total + added
if running_total == response_json['totalResults'] or added == 0:
# We've got them all.
logging.debug(f"All existing users for {app.slug}: {users}")
return users
else:
startIndex = startIndex + added
def _get_existing_users(self, app):
"""
Get all current user accounts from the given app via SCIM.
"""
scim_users = self._scim_list_users(app)
# Make a dictionary of the users, using their externalId as key, which
# is the kratos user ID.
users = {}
for u in scim_users:
kratos_id = u.get('externalId')
if not kratos_id:
logging.debug(f"Got user without externalId: {u}")
# Users that were created just-in-time when logging in to the
# app will not have `externalId` set, so we attempt to look up
# the user from our Stackspin database based on the app ID and
# SCIM ID.
app_role = db.session.query(AppRole).filter_by(
app_id=app.id,
scim_id=u['id']
).first()
if app_role is None:
logging.debug(f" SCIM ID {u['id']} not listed in database.")
# We can't find this app user in our Stackspin database, at
# least based on the SCIM ID. It could be that it was
# created before the introduction of SCIM, or was created
# on-the-fly on login by the app before SCIM got a chance
# to create it. To cover that case, we try to find the
# matching Stackspin user by email address.
try:
if app.slug == 'zulip':
email_address = u['userName']
else:
email_address = u['emails'][0]['value']
kratos_user = KratosUser.find_by_email(self.kratos_identity_api, email_address)
except KeyError:
# The `emails` field is not set, so give up.
kratos_user = None
except IndexError:
# The list of email addresses is empty, so give up.
kratos_user = None
if kratos_user is None:
# This user is not known at all by Stackspin, so
# we'll ignore it.
logging.debug(f" SCIM user unknown, ignoring.")
continue
# We found the user based on email address. We'll
# store the SCIM ID for this user in the Stackspin
# database so we don't need to do this email
# address matching again next time.
app_role = db.session.query(AppRole).filter_by(
app_id=app.id,
user_id=kratos_user.uuid
).first()
if app_role is not None:
app_role.scim_id = u['id']
db.session.commit()
logging.debug(f" Stored SCIM ID {u['id']} for user {kratos_user.uuid} for app {app.slug}")
kratos_id = kratos_user.uuid
else:
kratos_id = app_role.user_id
users[kratos_id] = User(kratos_id, u['id'], u['userName'], u['displayName'])
return users
def _get_existing_groups(self, app):
logging.debug(f"Getting list of current groups from {app.slug} via SCIM.")
url = f"{app.scim_url}/Groups"
scim_headers = {
'Authorization': 'Bearer ' + app.scim_token
}
response = requests.get(url, headers=scim_headers)
logging.debug(f"SCIM http status: {response.status_code}")
try:
response_json = response.json()
except json.decoder.JSONDecodeError as e:
logging.info("SCIM result was not json")
logging.info(response.content)
raise ProvisionError("Failed to get existing groups from {app.slug}")
logging.debug(f"got: {response_json}")
groups = {}
for group in response_json['Resources']:
members = {}
for member in group['members']:
scim_id = member['value']
members[scim_id] = User(None, scim_id, None, member['display'])
groups[group['id']] = Group(group['id'], group['displayName'], members)
return groups
def _provision_group(self, group, app):
logging.debug(f"Reconciling group {group.scim_id}")
scim_headers = {
'Authorization': 'Bearer ' + app.scim_token
}
member_data = [
{'value': member.scim_id, 'display': member.displayName, '$ref': member.ref(app.scim_url)}
for _, member in group.members.items()]
logging.debug(f"Will update admin group for {app.slug} with member data {member_data}")
data = {
'schemas': ['urn:ietf:params:scim:schemas:core:2.0:Group'],
'displayName': group.displayName,
'members': member_data,
}
url = f"{app.scim_url}/Groups/{group.scim_id}"
response = requests.put(url, headers=scim_headers, json=data)
logging.debug(f"SCIM http status: {response.status_code}")
try:
response_json = response.json()
except json.decoder.JSONDecodeError as e:
logging.info("SCIM result was not json")
logging.info(response.content)
raise ProvisionError("{app.slug} returned non-json data to SCIM group PUT.")
logging.debug(f"got: {response_json}")
@staticmethod
def store_attribute(attribute, user_id):
select_apps = select(
literal(user_id),
literal(attribute),
App.id
).where(App.scim_url != None)
insert = ScimAttribute.__table__.insert().prefix_with('IGNORE').from_select(
['user_id', 'attribute', 'app_id'],
select_apps
)
db.session.execute(insert)
@staticmethod
def done_attribute(attribute, user_id, app_id):
logging.debug(f"Deleting ScimAttribute with attribute={attribute} user_id={user_id} app_id={app_id}")
db.session.query(ScimAttribute).filter(
ScimAttribute.attribute == attribute,
ScimAttribute.user_id == user_id,
ScimAttribute.app_id == app_id,
).delete()
import functools
from posix_ipc import MessageQueue, O_CREAT, BusyError
import threading
# Signal to provisioning loop that we want to provision now.
provisioning_queue = MessageQueue('/stackspin-dashboard-provision-queue', O_CREAT)
def debounce(timeout: float):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
wrapper.func.cancel()
wrapper.func = threading.Timer(timeout, func, args, kwargs)
wrapper.func.start()
wrapper.func = threading.Timer(timeout, lambda: None)
return wrapper
return decorator
@debounce(1)
def request_provision():
try:
provisioning_queue.send("provision", timeout=0)
except BusyError:
# If we can't signal for some reason (queue limit reached), silently
# fail.
pass
def wait_provision():
# We first wait until there's any message in the queue.
provisioning_queue.receive()
# After that, we check if there are any more messages, to prevent a couple
# of (long) provisioning runs to be done back-to-back in case of multiple
# provisioning requests. Note however that if a request comes in during the
# middle of a provisioning run, we still do another one right after to make
# sure we propagate the latest changes right away.
try:
while True:
# We read with zero timeout, so we get an exception right away if
# the queue is empty.
provisioning_queue.receive(timeout=0)
except BusyError:
pass
from sqlalchemy import exc, text
from database import db
import logging
# We "reset" the alembic version history for Stackspin 2.2, to clean up our old
# mess of database migrations a bit, and in particular to make the transition
# easier to moving the source of truth for some of the data (list of apps) out
# of the database and into configmaps. This function deals with older clusters
# that have to be led through this transition. To determine if we need to do
# anything, we look at the `alembic_version` value in the database. If it's a
# legacy version, we delete the table so the alembic migration will view the
# database as "empty" and perform all new migrations on it. The new initial
# migration will have to handle that case specially, by checking if any tables
# already exist, and not do anything in that case.
def reset():
logging.info("Checking if alembic version needs to be reset.")
version = None
try:
result = db.session.execute(text("select version_num from alembic_version"))
for row in result:
version = row[0]
except exc.ProgrammingError:
# We assume this means the alembic_version table doesn't exist, which
# is expected for new clusters.
pass
logging.info(f"alembic version: {version}")
legacy_versions = ["fc0892d07771", "3fa0c38ea1ac", "e08df0bef76f", "b514cca2d47b", "5f462d2d9d25", "27761560bbcb"]
if version in legacy_versions:
logging.info("This is an old version: resetting.")
db.session.execute("drop table alembic_version")
else:
logging.info("This is not a known legacy version: not resetting.")
......@@ -8,43 +8,3 @@
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
......@@ -13,7 +13,9 @@ config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# We commented this out, because we want to configure logging in the app
# itself, not here.
# fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
# add your model's MetaData object here
......
"""Extend SCIM support to dynamic apps
Revision ID: 267d280db490
Revises: 825262488cd9
Create Date: 2024-04-12 11:49:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '267d280db490'
down_revision = '825262488cd9'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"app",
sa.Column(
"scim_url",
sa.Unicode(length=1024),
nullable=True
),
)
op.add_column(
"app",
sa.Column(
"scim_token",
sa.Unicode(length=1024),
nullable=True
),
)
op.add_column(
"app",
sa.Column(
"scim_group_support",
sa.Boolean(),
server_default='0',
nullable=False
),
)
# ID of user in app for SCIM purposes. The dashboard needs this so it can
# construct the SCIM URL to the app identifying the user.
op.add_column(
"app_role",
sa.Column(
"scim_id",
sa.Unicode(length=256),
nullable=True
),
)
op.create_index(
"app_role__app_id__scim_id",
"app_role",
["app_id", "scim_id"],
unique=False
)
def downgrade():
op.drop_column("app", "scim_url")
op.drop_column("app", "scim_token")
op.drop_column("app", "scim_group_support")
op.drop_index("app_role__app_id__scim_id", "app_role")
op.drop_column("app_role", "scim_id")
"""add-velero-as-app
Revision ID: 3fa0c38ea1ac
Revises: e08df0bef76f
Create Date: 2022-10-13 09:40:44.290319
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3fa0c38ea1ac'
down_revision = 'e08df0bef76f'
branch_labels = None
depends_on = None
def upgrade():
# Add monitoring app
op.execute(f'INSERT IGNORE INTO app (`name`, `slug`) VALUES ("Velero","velero")')
def downgrade():
pass
"""convert role column to table
Revision ID: 5f462d2d9d25
Revises: 27761560bbcb
Create Date: 2022-04-13 15:00:27.182898
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = "5f462d2d9d25"
down_revision = "27761560bbcb"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
role_table = op.create_table(
"role",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=64), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.add_column("app_role", sa.Column("role_id", sa.Integer(), nullable=True))
op.create_foreign_key(None, "app_role", "role", ["role_id"], ["id"])
# ### end Alembic commands ###
# Insert default role "admin" as ID 1
op.execute(sa.insert(role_table).values(id=1,name="admin"))
# Set role_id 1 to all current "admin" users
op.execute("UPDATE app_role SET role_id = 1 WHERE role = 'admin'")
# Drop old column
op.drop_column("app_role", "role")
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"app_role", sa.Column("role", mysql.VARCHAR(length=64), nullable=True)
)
op.drop_constraint(None, "app_role", type_="foreignkey")
op.drop_column("app_role", "role_id")
op.drop_table("role")
# ### end Alembic commands ###
"""Initial version after history reset: Create tables and fill the "role" one
Revision ID: 7d27395c892a
Revises:
Create Date: 2023-01-18 14:48:23.996261
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from sqlalchemy.engine.reflection import Inspector
# revision identifiers, used by Alembic.
revision = '7d27395c892a'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
inspector = Inspector.from_engine(conn)
tables = inspector.get_table_names()
if "app" not in tables:
op.create_table(
"app",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("slug", sa.String(length=64), nullable=False),
sa.Column("external", sa.Boolean(), server_default='0', nullable=False),
sa.Column("url", sa.String(length=128), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("slug"),
)
if "role" not in tables:
op.create_table(
"role",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=64), nullable=True),
sa.PrimaryKeyConstraint("id")
)
op.execute("INSERT INTO `role` (id, `name`) VALUES (1, 'admin')")
op.execute("INSERT INTO `role` (id, `name`) VALUES (2, 'user')")
op.execute("INSERT INTO `role` (id, `name`) VALUES (3, 'no access')")
if "app_role" not in tables:
op.create_table(
"app_role",
sa.Column("user_id", sa.String(length=64), nullable=False),
sa.Column("app_id", sa.Integer(), nullable=False),
sa.Column("role_id", sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint("user_id", "app_id"),
sa.ForeignKeyConstraint(["app_id"],["app.id"]),
sa.ForeignKeyConstraint(["role_id"],["role.id"])
)
if "oauthclient_app" not in tables:
op.create_table('oauthclient_app',
sa.Column('oauthclient_id', mysql.VARCHAR(length=64), nullable=False),
sa.Column('app_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('oauthclient_id'),
sa.ForeignKeyConstraint(['app_id'], ['app.id']),
mysql_default_charset='utf8mb3',
mysql_engine='InnoDB'
)
def downgrade():
op.drop_table("oauthclient_app")
op.drop_table("app_role")
op.drop_table("role")
op.drop_table("app")
"""Add SCIM support for user provisioning
Revision ID: 825262488cd9
Revises: fdb28e81f5c2
Create Date: 2023-03-08 10:50:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from sqlalchemy.engine.reflection import Inspector
from helpers.provision import ProvisionStatus
# revision identifiers, used by Alembic.
revision = '825262488cd9'
down_revision = 'fdb28e81f5c2'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"app_role",
sa.Column(
"provision_status",
sa.Enum(
ProvisionStatus,
native_enum=False,
length=32,
values_callable=lambda _: [str(member.value) for member in ProvisionStatus]
),
nullable=False,
default=ProvisionStatus.SyncNeeded,
server_default=ProvisionStatus.SyncNeeded.value
),
)
op.add_column(
"app_role",
sa.Column(
"last_provision_attempt",
sa.DateTime,
nullable=True
),
)
op.add_column(
"app_role",
sa.Column(
"last_provision_message",
sa.Unicode(length=256),
nullable=True
),
)
def downgrade():
op.drop_column("app_role", "provision_status")
op.drop_column("app_role", "last_provision_attempt")
op.drop_column("app_role", "last_provision_message")
"""Extend SCIM support to include some attributes during provisioning only when
they are changed, or the user is first created in the app.
Revision ID: 9ee5a7d65fa7
Revises: 267d280db490
Create Date: 2024-06-04 15:39:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9ee5a7d65fa7'
down_revision = '267d280db490'
branch_labels = None
depends_on = None
def upgrade():
# An entry in this table records that a certain user attribute needs to be
# set in a certain app via SCIM.
op.create_table(
"scim_attribute",
sa.Column("user_id", sa.String(length=64), nullable=False),
sa.Column("app_id", sa.Integer(), nullable=False),
sa.Column("attribute", sa.String(length=64), nullable=False),
sa.PrimaryKeyConstraint("user_id", "app_id", "attribute"),
sa.ForeignKeyConstraint(["app_id"],["app.id"]),
)
def downgrade():
op.drop_table("scim_attribute")
"""update apps and add 'user' and 'no access' role
Revision ID: b514cca2d47b
Revises: 5f462d2d9d25
Create Date: 2022-06-08 17:24:51.305129
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b514cca2d47b'
down_revision = '5f462d2d9d25'
branch_labels = None
depends_on = None
def upgrade():
# ### end Alembic commands ###
# Check and update app table in DB
apps = {
"dashboard": "Dashboard",
"wekan": "Wekan",
"wordpress": "WordPress",
"nextcloud": "Nextcloud",
"zulip": "Zulip"
}
# app table
app_table = sa.table('app', sa.column('id', sa.Integer), sa.column(
'name', sa.String), sa.column('slug', sa.String))
existing_apps = op.get_bind().execute(app_table.select()).fetchall()
existing_app_slugs = [app['slug'] for app in existing_apps]
for app_slug in apps.keys():
if app_slug in existing_app_slugs:
op.execute(f'UPDATE app SET `name` = "{apps.get(app_slug)}" WHERE slug = "{app_slug}"')
else:
op.execute(f'INSERT INTO app (`name`, slug) VALUES ("{apps.get(app_slug)}","{app_slug}")')
# Fetch all apps including newly created
existing_apps = op.get_bind().execute(app_table.select()).fetchall()
# Insert role "user" as ID 2
op.execute("INSERT INTO `role` (id, `name`) VALUES (2, 'user')")
# Insert role "no access" as ID 3
op.execute("INSERT INTO `role` (id, `name`) VALUES (3, 'no access')")
# Set role_id 2 to all current "user" users which by have NULL role ID
op.execute("UPDATE app_role SET role_id = 2 WHERE role_id IS NULL")
# Add 'no access' role for all users that don't have any roles for specific apps
app_roles_table = sa.table('app_role', sa.column('user_id', sa.String), sa.column(
'app_id', sa.Integer), sa.column('role_id', sa.Integer))
app_ids = [app['id'] for app in existing_apps]
app_roles = op.get_bind().execute(app_roles_table.select()).fetchall()
user_ids = set([app_role['user_id'] for app_role in app_roles])
for user_id in user_ids:
existing_user_app_ids = [x['app_id'] for x in list(filter(lambda role: role['user_id'] == user_id, app_roles))]
missing_user_app_ids = [x for x in app_ids if x not in existing_user_app_ids]
if len(missing_user_app_ids) > 0:
values = [{'user_id': user_id, 'app_id': app_id, 'role_id': 3} for app_id in missing_user_app_ids]
op.bulk_insert(app_roles_table, values)
def downgrade():
# Revert all users role_id to NULL where role is 'user'
op.execute("UPDATE app_role SET role_id = NULL WHERE role_id = 2")
# Delete role 'user' from roles
op.execute("DELETE FROM `role` WHERE id = 2")
# Delete all user app roles where role is 'no access' with role_id 3
op.execute("DELETE FROM app_role WHERE role_id = 3")
# Delete role 'no access' from roles
op.execute("DELETE FROM `role` WHERE id = 3")
"""Add fields for external apps
Revision ID: e08df0bef76f
Revises: b514cca2d47b
Create Date: 2022-09-23 16:38:06.557307
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e08df0bef76f'
down_revision = 'b514cca2d47b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('app', sa.Column('external', sa.Boolean(), server_default='0', nullable=False))
op.add_column('app', sa.Column('url', sa.String(length=128), nullable=True))
# ### end Alembic commands ###
# Add monitoring app
op.execute(f'INSERT IGNORE INTO app (`name`, `slug`) VALUES ("Monitoring","monitoring")')
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('app', 'url')
op.drop_column('app', 'external')
# ### end Alembic commands ###
"""empty message
Revision ID: 27761560bbcb
Revises:
Create Date: 2021-12-21 06:07:14.857940
"""Add tags for user management.
Revision ID: fdb28e81f5c2
Revises: 7d27395c892a
Create Date: 2023-11-21 14:55:00
"""
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from sqlalchemy.engine.reflection import Inspector
# revision identifiers, used by Alembic.
revision = "27761560bbcb"
down_revision = None
revision = 'fdb28e81f5c2'
down_revision = '7d27395c892a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"app",
"tag",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=64), nullable=True),
sa.Column("slug", sa.String(length=64), nullable=True),
sa.Column("name", sa.String(length=256), nullable=False),
sa.Column("colour", sa.String(length=64), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("slug"),
)
op.create_table(
"app_role",
"tag_user",
sa.Column("user_id", sa.String(length=64), nullable=False),
sa.Column("app_id", sa.Integer(), nullable=False),
sa.Column("role", sa.String(length=64), nullable=True),
sa.ForeignKeyConstraint(
["app_id"],
["app.id"],
),
sa.PrimaryKeyConstraint("user_id", "app_id"),
sa.Column("tag_id", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("user_id", "tag_id"),
sa.ForeignKeyConstraint(["tag_id"],["tag.id"]),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("app_role")
op.drop_table("app")
# ### end Alembic commands ###
op.drop_table("tag_user")
op.drop_table("tag")
APScheduler==3.11.0
# CLI creation kit
click==8.1.8
cryptography==44.0.2
Flask==3.1.0
Flask-Cors==5.0.1
flask-expects-json==1.7.0
Flask-JWT-Extended==4.7.1
Flask-Migrate==4.1.0
Flask-SQLAlchemy==3.1.1
gunicorn==23.0.0
jsonschema==4.23.0
# Templating kustomizations as part of app installation.
jinja2-base64-filters==0.1.4
kubernetes==32.0.1
pymysql==1.1.1
NamedAtomicLock==1.1.3
ory-kratos-client==1.3.8
ory-hydra-client==2.2.0
pip-install==1.3.5
posix-ipc==1.1.1
PyYAML==6.0.2
regex==2024.11.6
requests==2.32.3
requests-oauthlib==2.0.0
attrs==21.4.0
black==22.1.0
certifi==2021.10.8
cffi==1.15.0
charset-normalizer==2.0.12
click==8.0.4
cryptography==36.0.2
Flask==2.0.3
Flask-Cors==3.0.10
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --no-emit-index-url --output-file=requirements.txt --strip-extras requirements.in
#
alembic==1.15.1
# via flask-migrate
annotated-types==0.7.0
# via pydantic
apscheduler==3.11.0
# via -r requirements.in
attrs==25.3.0
# via
# jsonschema
# referencing
blinker==1.9.0
# via flask
cachetools==5.5.2
# via google-auth
certifi==2025.1.31
# via
# kubernetes
# requests
cffi==1.17.1
# via cryptography
charset-normalizer==3.4.1
# via requests
click==8.1.8
# via
# -r requirements.in
# flask
cryptography==44.0.2
# via -r requirements.in
durationpy==0.9
# via kubernetes
flask==3.1.0
# via
# -r requirements.in
# flask-cors
# flask-expects-json
# flask-jwt-extended
# flask-migrate
# flask-sqlalchemy
flask-cors==5.0.1
# via -r requirements.in
flask-expects-json==1.7.0
Flask-JWT-Extended==4.3.1
gunicorn==20.1.0
idna==3.3
install==1.3.5
itsdangerous==2.1.1
jsonschema==4.4.0
Jinja2==3.0.3
# via -r requirements.in
flask-jwt-extended==4.7.1
# via -r requirements.in
flask-migrate==4.1.0
# via -r requirements.in
flask-sqlalchemy==3.1.1
# via
# -r requirements.in
# flask-migrate
google-auth==2.38.0
# via kubernetes
greenlet==3.1.1
# via sqlalchemy
gunicorn==23.0.0
# via -r requirements.in
idna==3.10
# via requests
itsdangerous==2.2.0
# via flask
jinja2==3.1.6
# via
# flask
# jinja2-base64-filters
jinja2-base64-filters==0.1.4
kubernetes==24.2.0
MarkupSafe==2.1.1
mypy-extensions==0.4.3
oauthlib==3.2.0
pathspec==0.9.0
platformdirs==2.5.1
pycparser==2.21
PyJWT==2.3.0
pyrsistent==0.18.1
regex==2022.3.15
requests==2.27.1
requests-oauthlib==1.3.1
six==1.16.0
tomli==1.2.3
typing-extensions==4.1.1
urllib3==1.26.8
Werkzeug==2.0.3
ory-kratos-client==0.9.0a2
pymysql
Flask-SQLAlchemy
hydra-client
Flask-Migrate
# via -r requirements.in
jsonschema==4.23.0
# via
# -r requirements.in
# flask-expects-json
jsonschema-specifications==2024.10.1
# via jsonschema
kubernetes==32.0.1
# via -r requirements.in
mako==1.3.9
# via alembic
markupsafe==3.0.2
# via
# jinja2
# mako
# werkzeug
namedatomiclock==1.1.3
# via -r requirements.in
oauthlib==3.2.2
# via
# kubernetes
# requests-oauthlib
ory-hydra-client==2.2.0
# via -r requirements.in
ory-kratos-client==1.3.8
# via -r requirements.in
packaging==24.2
# via gunicorn
pip-install==1.3.5
# via -r requirements.in
posix-ipc==1.1.1
# via -r requirements.in
pyasn1==0.6.1
# via
# pyasn1-modules
# rsa
pyasn1-modules==0.4.1
# via google-auth
pycparser==2.22
# via cffi
pydantic==2.10.6
# via ory-kratos-client
pydantic-core==2.27.2
# via pydantic
pyjwt==2.10.1
# via flask-jwt-extended
pymysql==1.1.1
# via -r requirements.in
python-dateutil==2.9.0.post0
# via
# kubernetes
# ory-hydra-client
# ory-kratos-client
pyyaml==6.0.2
# via
# -r requirements.in
# kubernetes
referencing==0.36.2
# via
# jsonschema
# jsonschema-specifications
regex==2024.11.6
# via -r requirements.in
requests==2.32.3
# via
# -r requirements.in
# kubernetes
# requests-oauthlib
requests-oauthlib==2.0.0
# via
# -r requirements.in
# kubernetes
rpds-py==0.23.1
# via
# jsonschema
# referencing
rsa==4.9
# via google-auth
six==1.17.0
# via
# kubernetes
# python-dateutil
sqlalchemy==2.0.39
# via
# alembic
# flask-sqlalchemy
typing-extensions==4.12.2
# via
# alembic
# ory-kratos-client
# pydantic
# pydantic-core
# referencing
# sqlalchemy
tzlocal==5.3.1
# via apscheduler
urllib3==2.3.0
# via
# kubernetes
# ory-hydra-client
# ory-kratos-client
# requests
websocket-client==1.8.0
# via kubernetes
werkzeug==3.1.3
# via
# flask
# flask-cors
# flask-jwt-extended
This `web` directory is responsible for authentication frontend components.
It uses Tailwind for CSS; when making UI changes open a terminal in the `web` directory and run
`npx tailwindcss -i ./static/src/input.css -o ./static/css/main.css --watch`
......@@ -4,42 +4,53 @@ Hydra for OIDC sessions and MariaDB for application and role specifications.
The application provides also several command line options to interact with
the user entries in the database(s)"""
import ast
import json
import urllib.parse
import urllib.request
import ast
import hydra_client
import ory_hydra_client
from ory_hydra_client.api.o_auth2_api import OAuth2Api
from ory_hydra_client.models import (
AcceptOAuth2ConsentRequest,
AcceptOAuth2ConsentRequestSession,
AcceptOAuth2LoginRequest,
RejectOAuth2Request
)
import ory_hydra_client.exceptions as hydra_exceptions
import ory_kratos_client
from ory_kratos_client.api import v0alpha2_api as kratos_api
from flask import abort, redirect, render_template, request, current_app
from ory_kratos_client.api import frontend_api, identity_api
from ory_kratos_client.models.authenticator_assurance_level import AuthenticatorAssuranceLevel
from flask import abort, current_app, jsonify, redirect, render_template, request
from database import db
from helpers import KratosUser
from config import *
from web import web
from areas.apps import AppRole, App
from areas.apps.models import AppRole, App, OAuthClientApp
from areas.roles import RoleService
from areas.roles.models import Role
from areas.users.user_service import UserService
# This is a circular import and should be solved differently
# from app import db
# APIs
# Create HYDRA & KRATOS API interfaces
HYDRA = hydra_client.HydraAdmin(HYDRA_ADMIN_URL)
hydra_admin_api_configuration = \
ory_hydra_client.Configuration(host=HYDRA_ADMIN_URL, discard_unknown_keys=True)
hydra_client = ory_hydra_client.ApiClient(hydra_admin_api_configuration)
oauth2_api = OAuth2Api(hydra_client)
# Kratos has an admin and public end-point. We create an API for them
# both. The kratos implementation has bugs, which forces us to set
# the discard_unknown_keys to True.
kratos_admin_api_configuration = \
ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL, discard_unknown_keys=True)
KRATOS_ADMIN = \
kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_admin_api_configuration))
kratos_public_api_configuration = \
ory_kratos_client.Configuration(host=KRATOS_PUBLIC_URL, discard_unknown_keys=True)
KRATOS_PUBLIC = \
kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_public_api_configuration))
# both.
kratos_admin_api_configuration = ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL)
kratos_admin_client = ory_kratos_client.ApiClient(kratos_admin_api_configuration)
admin_identity_api = identity_api.IdentityApi(kratos_admin_client)
admin_frontend_api = frontend_api.FrontendApi(kratos_admin_client)
kratos_public_api_configuration = ory_kratos_client.Configuration(host=KRATOS_PUBLIC_URL)
kratos_public_client = ory_kratos_client.ApiClient(kratos_public_api_configuration)
kratos_public_frontend_api = frontend_api.FrontendApi(kratos_public_client)
ADMIN_ROLE_ID = 1
NO_ACCESS_ROLE_ID = 3
......@@ -59,9 +70,12 @@ def recovery():
flow = request.args.get("flow")
if not flow:
return redirect(KRATOS_PUBLIC_URL + "self-service/recovery/browser")
email = request.args.get("email", "")
response = redirect(KRATOS_PUBLIC_URL + "self-service/recovery/browser")
response.set_cookie("recovery_preset_email", email)
return response
return render_template("recover.html", api_url=KRATOS_PUBLIC_URL)
return render_template("recover.html", api_url=KRATOS_PUBLIC_URL, dashboard_url=DASHBOARD_URL)
@web.route("/settings", methods=["GET", "POST"])
......@@ -77,7 +91,7 @@ def settings():
if not flow:
return redirect(KRATOS_PUBLIC_URL + "self-service/settings/browser")
return render_template("settings.html", api_url=KRATOS_PUBLIC_URL)
return render_template("settings.html", api_url=KRATOS_PUBLIC_URL, dashboard_url=DASHBOARD_URL)
@web.route("/error", methods=["GET"])
......@@ -95,13 +109,13 @@ def error():
api_response = ""
try:
# Get Self-Service Errors
api_response = KRATOS_ADMIN.get_self_service_error(error_id)
api_response = admin_frontend_api.get_flow_error(error_id)
except ory_kratos_client.ApiException as ex:
current_app.logger.error(
"Exception when calling V0alpha2Api->get_self_service_error: %s\n",
"Exception when calling get_self_service_error: %s\n",
ex)
return render_template("error.html", error_message=api_response)
return render_template("error.html", dashboard_url=DASHBOARD_URL, error_message=api_response)
@web.route("/login", methods=["GET", "POST"])
......@@ -115,35 +129,109 @@ def login():
"""
# Check if we are logged in:
identity = get_auth()
if identity:
if 'name' in identity['traits']:
current_app.logger.info("/login: get_auth")
(identity, auth_response) = get_auth()
# We ignore the potential `auth_response` in this case: that's for telling
# the user they have to upgrade their session to include a second factor,
# but we're already on the login page so there's no use for that here --
# they'd be redirected by Kratos back to this same login page anyway,
# creating a redirect loop. Chances are that if `auth_response` is not
# None, we're actually serving or processing the TOTP/WebAuthn form here.
# List to contain messages pushed to the frontend
messages = list()
refresh = False
flow = request.args.get("flow")
if flow:
cookies = request.headers['cookie']
flow = kratos_public_frontend_api.get_login_flow(flow, cookie=cookies)
# current_app.logger.info("flow found in login: {}".format(flow))
refresh = flow.refresh
if refresh:
message = {
"id": "S_CONFIRM_CREDENTIALS",
"message": "Please confirm your credentials to complete this action.",
"type": "info"
}
# Not appending, depending on rendering in frontend now,
# see issue: #136
# messages.append(message)
try:
if flow.ui.messages is not None:
current_app.logger.info(f"Kratos messages: {flow.ui.messages}")
for msg in flow.ui.messages:
current_app.logger.info("Kratos message: " + msg.text)
if msg.id == 4000006:
message = {
"id": msg.id,
"message": "The provided login e-mail address or password is incorrect. Please try again.",
"type": "error"
}
else:
message = {
"id": msg.id,
"message": msg.text,
}
if msg.type == 'error':
message['type'] = 'error'
else:
message['type'] = 'info'
# Not appending, depending on rendering in frontend now,
# see issue: #136
# messages.append(message)
except ory_kratos_client.exceptions.ApiAttributeError as ex:
# This exception is expected when there are no messages
pass
except Exception as ex:
# An unkown exception happens, we log the error but continue as it
# only affects the messages part
current_app.logger.error("Unknown exception: " + str(ex))
if identity and not refresh:
# We are already logged in, and don't need to refresh.
if 'name' in identity.traits:
# Add a space in front of the "name" so the template can put it
# between "Welcome" and the comma
name = " " + identity['traits']['name']
name = " " + identity.traits['name']
else:
name = ""
return render_template("loggedin.html", api_url=KRATOS_PUBLIC_URL, name=name)
flow = request.args.get("flow")
return render_template("loggedin.html",
api_url=KRATOS_PUBLIC_URL,
dashboard_url=DASHBOARD_URL,
name=name)
# If we do not have a flow, get one.
if not flow:
current_app.logger.info("No flow found. Redirecting to Kratos to obtain one.")
return redirect(KRATOS_PUBLIC_URL + "self-service/login/browser")
return render_template("login.html", api_url=KRATOS_PUBLIC_URL)
# If we end up here, then either:
# `identity and refresh`
# User is already logged in, but "refresh" is specified, meaning that
# we should ask the user to authenticate again. This is necessary when
# you want to change protected fields (password, 2FA) in the
# self-service settings, and your session is too old.
# or `not identity`
# User is not logged in yet.
# In either case, we present the login screen now.
return render_template("login.html", api_url=KRATOS_PUBLIC_URL,
dashboard_url=DASHBOARD_URL, messages=messages,
demo=DEMO_INSTANCE)
@web.route("/auth", methods=["GET", "POST"])
def auth():
"""Authorize an user for an application
If an application authenticated against the IdP (Idenitity Provider), if
there are no active session, the user is forwarded to the login page.
"""Authorize a user for an application
If an application authenticated against the IdP (Identity Provider), if
there are no active sessions, the user is forwarded to the login page.
This is the entry point for those authorization requests. The challenge
as provided, is verified. If an active user is logged in, the request
is accepted and the user is returned to the application. If the user is not
logged in yet, it redirects to the login page
logged in yet, it redirects to the login page.
:param challenge: challenge as given by Hydra
:return: redirect to login or application/idp
"""
......@@ -163,7 +251,13 @@ def auth():
abort(400, description="Challenge required when requesting authorization")
# Check if we are logged in:
identity = get_auth()
current_app.logger.info("/auth: get_auth")
(identity, auth_response) = get_auth()
if auth_response is not None:
# According to `get_auth`, we have to send the user a response already,
# probably a redirect to let the user provide their second factor.
return auth_response
# If the user is not logged in yet, we redirect to the login page
# but before we do that, we set the "flow_state" cookie to auth.
......@@ -185,26 +279,33 @@ def auth():
current_app.logger.info("User is logged in. We can authorize the user")
try:
login_request = HYDRA.login_request(challenge)
except hydra_client.exceptions.NotFound:
login_request = oauth2_api.get_o_auth2_login_request(challenge)
except hydra_exceptions.NotFoundException:
current_app.logger.error(
f"Not Found. Login request not found. challenge={challenge}"
)
abort(404, description="Login request not found. Please try again.")
except hydra_client.exceptions.HTTPError:
except hydra_exceptions.ApiException:
current_app.logger.error(
f"Conflict. Login request has been used already. challenge={challenge}"
)
abort(503, description="Login request already used. Please try again.")
# Authorize the user
# False positive: pylint: disable=no-member
redirect_to = login_request.accept(
identity.id,
remember=True,
# Remember session for 7d
remember_for=60 * 60 * 24 * 7,
)
try:
redirect_to = oauth2_api.accept_o_auth2_login_request(
challenge,
accept_o_auth2_login_request=AcceptOAuth2LoginRequest(
identity.id,
remember=True,
# Remember session for 7d
remember_for=60 * 60 * 24 * 7,
)
).redirect_to
except Exception as e:
current_app.logger.error("Failure during accepting login request. Redirecting to logout, hopefully to wipe cookies")
current_app.logger.error(e)
return redirect("logout")
return redirect(redirect_to)
......@@ -212,9 +313,8 @@ def auth():
@web.route("/consent", methods=["GET", "POST"])
def consent():
"""Get consent
For now, it just allows every user. Eventually this function should check
the roles and settings of a user and provide that information to the
application.
Rather than get consent from the end-user, this checks whether the user
should have access to the given app based on stackspin roles.
:param consent_challenge: challenge as given by Hydra
:return: redirect to login or render error
"""
......@@ -225,14 +325,32 @@ def consent():
403, description="Consent request required. Do not call this page directly"
)
try:
consent_request = HYDRA.consent_request(challenge)
except hydra_client.exceptions.NotFound:
consent_request = oauth2_api.get_o_auth2_consent_request(challenge)
except hydra_exceptions.NotFoundException:
current_app.logger.error(f"Not Found. Consent request {challenge} not found")
abort(404, description="Consent request does not exist. Please try again")
except hydra_client.exceptions.HTTPError:
except hydra_exceptions.ApiException:
current_app.logger.error(f"Conflict. Consent request {challenge} already used")
abort(503, description="Consent request already used. Please try again")
if ENFORCE_2FA:
# Check for session status, in particular 2FA.
cookie = get_kratos_cookie()
if not cookie:
current_app.logger.info("consent: no kratos cookie set, redirecting to set up 2fa")
response = redirect(KRATOS_PUBLIC_URL + "self-service/settings/browser")
response.set_cookie('stackspin_context', '2fa-required')
return response
session = kratos_public_frontend_api.to_session(cookie=cookie)
# Check session aal.
aal = session.authenticator_assurance_level
current_app.logger.info(f"aal: {aal}")
if aal == AuthenticatorAssuranceLevel('aal1'):
current_app.logger.info("aal is only aal1, so not accepting consent request")
response = redirect(KRATOS_PUBLIC_URL + "self-service/settings/browser")
response.set_cookie('stackspin_context', '2fa-required')
return response
# Get information about this consent request:
# False positive: pylint: disable=no-member
try:
......@@ -242,11 +360,11 @@ def consent():
if isinstance(consent_client, str):
consent_client = ast.literal_eval(consent_client)
app_id = consent_client.get("client_id")
client_id = consent_client.get("client_id")
# False positive: pylint: disable=no-member
kratos_id = consent_request.subject
current_app.logger.info(f"Info: Found kratos_id {kratos_id}")
current_app.logger.info(f"Info: Found app_id {app_id}")
current_app.logger.info(f"Info: Found client_id {client_id}")
except Exception as ex:
current_app.logger.error(
......@@ -255,11 +373,11 @@ def consent():
current_app.logger.error(f"Error: {ex}")
current_app.logger.error(f"Client: {consent_request.client}")
current_app.logger.error(f"Subject: {consent_request.subject}")
abort(501, description="Internal error occured")
abort(501, description="Internal error occurred")
# Get the related user object
current_app.logger.info(f"Info: Getting user from admin {kratos_id}")
user = KratosUser(KRATOS_ADMIN, kratos_id)
user = KratosUser(admin_identity_api, kratos_id)
if not user:
current_app.logger.error(f"User not found in database: {kratos_id}")
abort(401, description="User not found. Please try again.")
......@@ -277,65 +395,95 @@ def consent():
# If the user is dashboard admin admin is for all
if role_object is not None and role_object.role_id == ADMIN_ROLE_ID:
current_app.logger.info(f"Info: User has admin dashboard role")
current_app.logger.info(f"Providing consent to {app_id} for {kratos_id}")
current_app.logger.info(f"{kratos_id} was granted admin access to {app_id}")
current_app.logger.info(f"Providing consent to {client_id} for {kratos_id}")
current_app.logger.info(f"{kratos_id} was granted admin access to {client_id}")
# Get claims for this user, provided the current app
claims = user.get_claims(app_id, ['admin'])
claims = user.get_claims(client_id, ['admin'])
current_app.logger.info(f"claims: {claims}")
return redirect(
consent_request.accept(
grant_scope=consent_request.requested_scope,
grant_access_token_audience=consent_request.requested_access_token_audience,
session=claims,
)
oauth2_api.accept_o_auth2_consent_request(
challenge,
accept_o_auth2_consent_request=AcceptOAuth2ConsentRequest(
grant_scope=consent_request.requested_scope,
grant_access_token_audience=consent_request.requested_access_token_audience,
session=AcceptOAuth2ConsentRequestSession(**claims),
)
).redirect_to
)
# Get role on this app
app_obj = db.session.query(App).filter(App.slug == app_id).first()
# Resolve to which app the client_id belongs.
try:
app_obj = db.session.query(OAuthClientApp).filter(OAuthClientApp.oauthclient_id == client_id).first().app
except AttributeError:
current_app.logger.error(f"Could not find app for client {client_id}")
return redirect(
oauth2_api.reject_o_auth2_consent_request(
challenge,
reject_o_auth2_request=RejectOAuth2Request(
error="No access",
error_description="It appears you do not have access to this app.",
error_hint="Contact your administrator",
status_code=401,
)
).redirect_to
)
# Default access level
roles = []
if app_obj:
role_object = (
db.session.query(AppRole)
.filter(AppRole.app_id == app_obj.id)
.filter(AppRole.user_id == user.uuid)
.first()
)
# Role ID 3 is always "No access" due to migration b514cca2d47b
if role_object is None or role_object.role_id is None or role_object.role_id == NO_ACCESS_ROLE_ID:
# If there is no role in app_roles or the role_id for an app is null user has no permissions
current_app.logger.error(f"User has no access for: {app_obj.name}")
return redirect(
consent_request.reject(
role_object = (
db.session.query(AppRole)
.filter(AppRole.app_id == app_obj.id)
.filter(AppRole.user_id == user.uuid)
.first()
)
# Role ID 3 is always "No access" due to migration b514cca2d47b
if role_object is None or role_object.role_id is None or role_object.role_id == NO_ACCESS_ROLE_ID:
# If there is no role in app_roles or the role_id for an app is null user has no permissions
current_app.logger.error(f"User has no access for: {app_obj.name}")
return redirect(
oauth2_api.reject_o_auth2_consent_request(
challenge,
reject_o_auth2_request=RejectOAuth2Request(
error="No access",
error_description="The user has no access for app",
error_description="It appears you do not have access to this app.",
error_hint="Contact your administrator",
status_code=401,
)
)
else:
roles.append(role_object.role.name)
).redirect_to
)
else:
roles.append(role_object.role.name)
current_app.logger.info(f"Using '{roles}' when applying consent for {kratos_id}")
# Get claims for this user, provided the current app
claims = user.get_claims(app_id, roles)
claims = user.get_claims(client_id, roles)
current_app.logger.info(f"claims: {claims}")
# pylint: disable=fixme
# TODO: Need to implement checking claims here, once the backend for that is
# developed
current_app.logger.info(f"Providing consent to {app_id} for {kratos_id}")
current_app.logger.info(f"{kratos_id} was granted access to {app_id}")
current_app.logger.info(f"Providing consent to {client_id} for {kratos_id}")
current_app.logger.info(f"{kratos_id} was granted access to {client_id}")
# False positive: pylint: disable=no-member
return redirect(
consent_request.accept(
grant_scope=consent_request.requested_scope,
grant_access_token_audience=consent_request.requested_access_token_audience,
session=claims,
)
)
try:
redirectUrl = oauth2_api.accept_o_auth2_consent_request(
challenge,
accept_o_auth2_consent_request=AcceptOAuth2ConsentRequest(
grant_scope=consent_request.requested_scope,
grant_access_token_audience=consent_request.requested_access_token_audience,
session=AcceptOAuth2ConsentRequestSession(**claims),
)
).redirect_to
except:
# If an unexpected error occurs, logout, hopefully that wipes the
# relevant cookies
current_app.logger.error('Fatal processing consent, redirect to logout:' + str(e))
return redirect("logout")
current_app.logger.info(f"Redirect to: {redirectUrl}")
return redirect(redirectUrl)
@web.route("/status", methods=["GET", "POST"])
def status():
......@@ -343,7 +491,7 @@ def status():
Show if there is an user is logged in. If not shows: not-auth
"""
auth_status = get_auth()
(auth_status, auth_response) = get_auth()
if auth_status:
return auth_status.id
......@@ -354,27 +502,43 @@ def get_auth():
"""Checks if user is logged in
Queries the cookies. If an authentication cookie is found, it
checks with Kratos if the cookie is still valid. If so,
the profile is returned. Otherwise False is returned.
:return: Profile or False if not logged in
the profile is returned. Otherwise False is returned, possibly with a
response to send to the user, for redirecting them to the kratos-suggested
url, for providing 2FA in particular.
:return: (Profile, None) or (False, None) or (False, Response)
"""
cookie = get_kratos_cookie()
if not cookie:
return False
return False, None
# Given a cookie, check if it is valid and get the profile
try:
api_response = KRATOS_PUBLIC.to_session(cookie=cookie)
api_response = kratos_public_frontend_api.to_session(cookie=cookie)
# Get all traits from ID
return api_response.identity
return api_response.identity, None
except ory_kratos_client.ApiException as ex:
# If it fails because the client needs to provide 2FA, we return a
# redirect response for use by the caller of this function.
if ex.body is not None:
body = json.loads(ex.body)
current_app.logger.info("Error in to_session: {}".format(body))
error_id = body.get('error', {}).get('id')
if error_id == 'session_aal2_required':
current_app.logger.info("2FA requested by Kratos. Suggesting to redirect the user.")
redirect_url = body.get('redirect_browser_to')
if redirect_url is None:
response = None
else:
response = redirect(redirect_url)
return False, response
current_app.logger.error(
f"Exception when calling V0alpha2Api->to_session(): {ex}\n"
f"Exception when calling to_session(): {ex}\n"
)
return False
return False, None
def get_kratos_cookie():
......@@ -391,19 +555,28 @@ def get_kratos_cookie():
return cookie
@web.route("/prelogout", methods=["GET"])
def prelogout():
"""Handles the Hydra OpenID Connect Logout flow
@web.route("/logout", methods=["GET"])
def logout():
"""Handles the Hydra OpenID Connect Logout flow and Kratos logout flow.
Steps:
1. Hydra's /oauth2/sessions/logout endpoint is called by an application
2. Hydra calls this endpoint with a `logout_challenge` get parameter
3. We retrieve the logout request using the challenge
4. We accept the Hydra logout request
5. We redirect to Hydra to clean-up cookies.
6. Hyrda calls back to us with a post logout handle (/logout)
4. We accept the Hydra logout request. This returns a URL -- let's call it
"next redirect" -- that we should redirect the browser to to finish the
Hydra logout.
5. We create a Kratos logout flow, setting its `return_to` parameter to the
"next redirect". This returns a Kratos logout URL.
6. We return a small HTML page to the browser, based on the `clear.html`
template, which clears dashboard local storage and redirects to the Kratos
logout URL.
7. The browser follows that redirect, Kratos does its thing and redirects
to the "next redirect".
8. The browser follows the "next redirect", Hydra does its thing and
redirects to the "post-logout URL". We set that to the dashboard root URL
by default, but OIDC clients can override it to something else. For
example, Nextcloud sets it to the root Nextcloud URL.
Args:
logout_challenge (string): Reference to a Hydra logout challenge object
......@@ -411,63 +584,81 @@ def prelogout():
Returns:
Redirect to the url that is provided by the LogoutRequest object.
"""
# Generally, if we encounter errors during these steps we redirect the user
# to the beginning of the logout procedure, which is
# `https://${hydra_domain}/oauth2/sessions/logout`.
new_logout_url = f"{HYDRA_PUBLIC_URL}/oauth2/sessions/logout"
# We should have been redirected here by hydra which also sets the
# `logout_challenge` parameter.
challenge = request.args.get("logout_challenge")
current_app.logger.info("Logout request: challenge=%s", challenge)
if not challenge:
abort(403)
current_app.logger.info("No challenge set.")
current_app.logger.info("Redirecting to hydra logout: %s", new_logout_url)
return redirect(new_logout_url)
try:
logout_request = HYDRA.logout_request(challenge)
except hydra_client.exceptions.NotFound:
logout_request = oauth2_api.get_o_auth2_logout_request(challenge)
except hydra_exceptions.NotFoundException:
current_app.logger.error("Logout request with challenge '%s' not found", challenge)
abort(404, "Hydra session invalid or not found")
except hydra_client.exceptions.HTTPError:
return redirect(new_logout_url)
except hydra_exceptions.ApiException:
current_app.logger.error(
"Conflict. Logout request with challenge '%s' has been used already.",
challenge)
abort(503)
current_app.logger.info("Redirecting to hydra logout: %s", new_logout_url)
return redirect(new_logout_url)
current_app.logger.info("Logout request hydra, subject %s", logout_request.subject)
# Accept logout request and direct to hydra to remove cookies
# Accept logout request. The `redirect_to` that we get is what we have to
# redirect the browser to to finish the hydra logout (clear cookies, etc.)
# and after that get redirected to the configured post-logout URL. We store
# the `redirect_to` URL so we can pass it to kratos below (as `return_to`),
# so the browser will get redirected to `redirect_to` after the kratos
# logout is finished.
try:
hydra_return = logout_request.accept(subject=logout_request.subject)
if hydra_return:
return redirect(hydra_return)
hydra_return = oauth2_api.accept_o_auth2_logout_request(challenge)
next_redirect = hydra_return.redirect_to
except Exception as ex:
current_app.logger.info("Error logging out hydra: %s", str(ex))
current_app.logger.info("Hydra logout not completed. Redirecting to kratos logout, maybe user removed cookies manually")
return redirect("logout")
@web.route("/logout", methods=["GET"])
def logout():
"""Handles the Kratos Logout flow
Steps:
1. We got here from hyrda
2. We retrieve the Kratos cookie from the browser
3. We generate a Kratos logout URL
4. We redirect to the Kratos logout URIL
"""
current_app.logger.info("Error accepting hydra logout request: %s", str(ex))
next_redirect = DASHBOARD_URL
# Now end the kratos session.
kratos_cookie = get_kratos_cookie()
if not kratos_cookie:
# No kratos cookie, already logged out
current_app.logger.info("Expected kratos cookie but not found. Redirecting to login");
return redirect("login")
# No kratos cookie, already logged out from kratos.
current_app.logger.info("Expected kratos cookie but not found. Skipping kratos logout but continuing other logout steps.");
# We skip the Kratos logout, but we still need to follow
# `next_redirect` -- probably the Hydra logout URL -- and clear
# dashboard storage.
return render_template("clear.html",
url=next_redirect)
try:
# Create a Logout URL for Browsers
kratos_api_response = \
KRATOS_ADMIN.create_self_service_logout_flow_url_for_browsers(
cookie=kratos_cookie)
current_app.logger.info(f"Creating logout flow, with return_to={next_redirect}")
kratos_api_response = kratos_public_frontend_api.create_browser_logout_flow(
return_to=next_redirect,
cookie=kratos_cookie)
current_app.logger.info("Kratos api response to creating logout flow:")
current_app.logger.info(kratos_api_response)
return render_template("clear.html",
url=kratos_api_response.logout_url)
except ory_kratos_client.ApiException as ex:
current_app.logger.error("Exception when calling"
" V0alpha2Api->create_self_service_logout_flow_url_for_browsers: %s\n",
" create_browser_logout_flow: %s\n",
ex)
return redirect(kratos_api_response.logout_url)
current_app.logger.info("Redirecting to hydra logout: %s", new_logout_url)
return redirect(new_logout_url)
if DEMO_INSTANCE:
@web.route("/demo-user", methods=["POST"])
def demo_user():
data = request.get_json()
defaults = {
"name": "",
"app_roles": [{"name": "dashboard", "role_id": Role.ADMIN_ROLE_ID}],
}
UserService.post_user({**defaults, **data})
return jsonify("User created successfully. You should receive an email to confirm your address and set a password.")
/* 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
......@@ -16,194 +16,316 @@
*/
// In default configuration the dashboard is on '/'. This can be overwritten
// before calling the scripts (and configured by the flask app).
var dashboard_url = "";
// Render a message by appending the data to the messages box. The message id is
// available, potentially for future translations/locale handling
// @param string id Message ID\
// @param string message Message in the default language (English)
// @param string type Type of message, currently only "error" renders in
// 'danger'. Others render in 'info'
function renderMessage(id, message, type) {
// Default class for messages
var class_name = "info";
if (type == "error") {
class_name = "danger";
}
let html = '<div class="alert alert-' + class_name + '">';
html = html + message;
html = html + "</div>";
$("#messages").append(html);
}
// Check if an auth flow is configured and redirect to auth page in that
// case.
function check_flow_auth() {
var state = Cookies.get('flow_state');
var url = Cookies.get('auth_url');
if (state == 'auth') {
Cookies.set('flow_state', '');
window.location.href = url;
}
var state = Cookies.get("flow_state");
window.console.log("check_flow_auth: flow_state=" + state);
var url = Cookies.get("auth_url");
// Redirect to the specified URL
if (state == "auth") {
Cookies.set("flow_state", "");
window.location.href = url;
return;
}
if (state == "recovery") {
Cookies.set("flow_state", "");
// Set a custom cookie so the settings page knows we're in
// recovery context and can open the right tab.
Cookies.set("stackspin_context", "recovery");
window.location.href = api_url + "/self-service/settings/browser";
return;
}
// Some older stackspin releases, do not provide the dashboard_url,
// flask writes 'None' as string in that case, we want to cover those
// cases and revert to the default
if (dashboard_url == "None" || dashboard_url == "") {
dashboard_url = window.location.protocol + "//" + window.location.host;
}
// If no redirect is set, redirect to dashboard
window.location.href = dashboard_url + "/login";
}
// Check if there if the flow is expired, if so, reset the cookie
function check_flow_expired() {
var state = Cookies.get('flow_state');
var state = Cookies.get("flow_state");
if (state == 'flow_expired') {
Cookies.set('flow_state', '');
$('#contentFlowExpired').show();
}
if (state == "flow_expired") {
Cookies.set("flow_state", "");
$("#contentFlowExpired").show();
}
}
// The script executed on login flows
function flow_login() {
var flow = $.urlParam('flow');
var uri = api_url + 'self-service/login/flows?id=' + flow;
// Query the Kratos backend to know what fields to render for the
// current flow
$.ajax({
type: 'GET',
url: uri,
success: function (data) {
// Render login form (group: password)
var form_html = render_form(data, 'password');
$('#contentLogin').html(form_html);
var messages_html = render_messages(data);
$('#contentMessages').html(messages_html);
},
complete: function (obj) {
// If we get a 410, the flow is expired, need to refresh the flow
if (obj.status == 410) {
Cookies.set('flow_state', 'flow_expired');
// If we call the page without arguments, we get a new flow
window.location.href = 'login';
}
},
});
var flow = $.urlParam("flow");
var uri = api_url + "self-service/login/flows?id=" + flow;
// Query the Kratos backend to know what fields to render for the
// current flow
$.ajax({
type: "GET",
url: uri,
success: function (data) {
// Determine which groups to show.
window.console.log("flow_login: selecting UI groups to process");
var groups = scrape_groups(data);
for (const group of groups) {
window.console.log("flow_login: processing group " + group);
var form_html = render_form(data, group, "login");
if (group == "oidc" && groups.has("password")) {
// Show a separator between password login and oidc login.
$("#separator_oidc").removeClass("hide");
}
$("#contentLogin_" + group).html(form_html);
// Hide the recovery link on the 2FA entry
// forms. It's not really useful at that point, and
// you may get a redirect loop if you use it.
if (group == 'totp' || group == 'webauthn') {
$("#recoveryLink").hide();
}
}
render_messages(data);
$("#totp_code").focus();
$("#identifier").focus();
},
complete: function (obj) {
// If we get a 410, the flow is expired, need to refresh the flow
if (obj.status == 410) {
Cookies.set("flow_state", "flow_expired");
// If we call the page without arguments, we get a new flow
window.location.href = "login";
}
},
});
}
// This is called after a POST on settings. It tells if the save was
// successful and display / handles based on that outcome
function flow_settings_validate() {
var flow = $.urlParam('flow');
var uri = api_url + 'self-service/settings/flows?id=' + flow;
$.ajax({
type: 'GET',
url: uri,
success: function (data) {
// We had success. We save that fact in our flow_state
// cookie and regenerate a new flow
if (data.state == 'success') {
Cookies.set('flow_state', 'settings_saved');
// Redirect to generate new flow ID
window.location.href = 'settings';
} else {
// There was an error, Kratos does not specify what is
// wrong. So we just show the general error message and
// let the user figure it out. We can re-use the flow-id
$('#contentProfileSaveFailed').show();
// For now, this code assumes that only the password can fail
// validation. Other forms might need to be added in the future.
html = render_form(data, 'password');
$('#contentPassword').html(html);
}
},
});
var flow = $.urlParam("flow");
var uri = api_url + "self-service/settings/flows?id=" + flow;
$.ajax({
type: "GET",
url: uri,
success: function (data) {
// We had success. We save that fact in our flow_state
// cookie and regenerate a new flow
if (data.state == "success") {
Cookies.set("flow_state", "settings_saved");
var activeTab = $("#pills-tab .active").attr("id");
sessionStorage.setItem("settings_tab", activeTab);
// Redirect to generate new flow ID
window.location.href = "settings";
} else {
// There was an error, Kratos does not specify what is
// wrong. So we just show the general error message and
// let the user figure it out. We can re-use the flow-id
$("#contentProfileSaveFailed").show();
// For now, this code assumes that only the password can fail
// validation. Other forms might need to be added in the future.
html = render_form(data, "password", "validation");
$("#contentPassword").html(html);
}
},
});
}
// Render the settings flow, this is where users can change their personal
// settings, like name and password. The form contents are defined by Kratos
// settings, like name, password and second factors (2FA, WebAuthn). The form
// contents are defined by Kratos.
function flow_settings() {
// Get the details from the current flow from kratos
var flow = $.urlParam('flow');
var uri = api_url + 'self-service/settings/flows?id=' + flow;
$.ajax({
type: 'GET',
url: uri,
success: function (data) {
var state = Cookies.get('flow_state');
// If we have confirmation the settings are saved, show the
// notification
if (state == 'settings_saved') {
$('#contentProfileSaved').show();
Cookies.set('flow_state', 'settings');
}
// Hide prfile section if we are in recovery state
// so the user is not confused by other fields. The user
// probably want to setup a password only first.
if (state == 'recovery') {
$('#contentProfile').hide();
}
// Render the password & profile form based on the fields we got
// from the API
var html = render_form(data, 'password');
$('#contentPassword').html(html);
html = render_form(data, 'profile');
$('#contentProfile').html(html);
// If the submit button is hit, execute the POST with Ajax.
$('#formpassword').submit(function (e) {
// avoid to execute the actual submit of the form.
e.preventDefault();
var form = $(this);
var url = form.attr('action');
$.ajax({
type: 'POST',
url: url,
data: form.serialize(),
complete: function (obj) {
// Validate the settings
flow_settings_validate();
},
});
});
},
complete: function (obj) {
// If we get a 410, the flow is expired, need to refresh the flow
if (obj.status == 410) {
Cookies.set('flow_state', 'flow_expired');
window.location.href = 'settings';
}
},
});
// Get the details from the current flow from kratos
var flow = $.urlParam("flow");
var uri = api_url + "self-service/settings/flows?id=" + flow;
$.ajax({
type: "GET",
url: uri,
success: function (data) {
var state = Cookies.get("flow_state");
var context = Cookies.get("stackspin_context");
// If we have confirmation the settings are saved, show the
// notification
if (state == "settings_saved") {
$("#contentProfileSaved").show();
var activeTab = sessionStorage.getItem("settings_tab");
$("#" + activeTab).tab("show");
Cookies.set("flow_state", "settings");
}
// Hide everything except password section if we are in recovery state,
// so the user is not confused by other fields. The user
// probably wants to setup a password only first.
if (state == "recovery") {
$("#contentProfile").hide();
$("#contentTotp").hide();
$("#contentWebAuthn").hide();
}
// Render messages given from the API
render_messages(data);
// Render the settings forms based on the fields we got
// from the API.
var html = render_form(data, "password", "settings");
$("#pills-password").html(html);
html = render_form(data, "profile", "settings");
$("#pills-profile").html(html);
html = render_form(data, "totp", "settings");
$("#pills-totp").html(html);
html = render_form(data, "webauthn", "settings");
$("#pills-webauthn").html(html);
// If the submit button is hit, execute the POST with Ajax.
$("#formpassword").submit(function (e) {
// avoid to execute the actual submit of the form.
e.preventDefault();
var form = $(this);
var url = form.attr("action");
$.ajax({
type: "POST",
url: url,
data: form.serialize(),
complete: function (obj) {
// Validate the settings
flow_settings_validate();
},
});
});
// If we are in recovery context, switch to the password tab.
if (context == "recovery") {
$("#pills-password-tab").tab("show");
Cookies.set("stackspin_context", "");
}
// If the user is required to set up 2FA, switch to
// that tab and show a message.
if (context == "2fa-required") {
$('#pills-totp-tab').tab('show');
$("#contentMessages").html('Setting up a second factor is required to continue.');
$("#contentMessages").addClass("alert");
$("#contentMessages").addClass("alert-warning");
Cookies.set('stackspin_context', '');
}
},
complete: function (obj) {
// If we get a 410, the flow is expired, need to refresh the flow
if (obj.status == 410) {
Cookies.set("flow_state", "flow_expired");
window.location.href = "settings";
}
},
error: function (xhr, textStatus, errorThrown) {
// Check if we got a 403 error from Kratos.
if (textStatus == "error" && xhr.status == 403) {
var response = $.parseJSON(xhr.responseText);
window.console.log(response);
if (response.error.id == "session_aal2_required") {
// Redirect so user can enter 2FA.
window.location.href = response.redirect_browser_to;
return;
}
}
// There was another error, one we don't specifically prepared for.
$("#contentProfileSaveFailed").show();
// For now, this code assumes that only the password can fail
// validation. Other forms might need to be added in the future.
html = render_form(data, "password", "validation");
$("#contentPassword").html(html);
},
});
}
function flow_recover() {
var flow = $.urlParam('flow');
var uri = api_url + 'self-service/recovery/flows?id=' + flow;
$.ajax({
type: 'GET',
url: uri,
success: function (data) {
// Render the recover form, method 'link'
var html = render_form(data, 'link');
$('#contentRecover').html(html);
// Do form post as an AJAX call
$('#formlink').submit(function (e) {
// avoid to execute the actual submit of the form.
e.preventDefault();
var form = $(this);
var url = form.attr('action');
// keep stat we are in recovery
Cookies.set('flow_state', 'recovery');
$.ajax({
type: 'POST',
url: url,
data: form.serialize(), // serializes the form's elements.
success: function (data) {
// Show the request is sent out
$('#contentRecover').hide();
$('#contentRecoverRequested').show();
},
});
});
},
complete: function (obj) {
// If we get a 410, the flow is expired, need to refresh the flow
if (obj.status == 410) {
Cookies.set('flow_state', 'flow_expired');
window.location.href = 'recovery';
}
},
});
var flow = $.urlParam("flow");
var uri = api_url + "self-service/recovery/flows?id=" + flow;
$.ajax({
type: "GET",
url: uri,
success: function (data) {
// Render the recover form, method 'link'
var html = render_form(data, "link", "recovery");
$("#contentRecover").html(html);
// Do form post as an AJAX call
$("#formlink").submit(function (e) {
// avoid to execute the actual submit of the form.
e.preventDefault();
var form = $(this);
var url = form.attr("action");
// keep stat we are in recovery
Cookies.set("flow_state", "recovery");
$.ajax({
type: "POST",
url: url,
data: form.serialize(), // serializes the form's elements.
success: function (data) {
// Show the request is sent out
$("#contentRecover").hide();
$("#contentRecoverRequested").show();
},
});
});
},
complete: function (obj) {
// If we get a 410, the flow is expired, need to refresh the flow
if (obj.status == 410) {
Cookies.set("flow_state", "flow_expired");
window.location.href = "recovery";
}
},
});
}
// Based on Kratos UI data, decide which node groups to process.
function scrape_groups(data) {
var nodes = new Set();
for (const node of data.ui.nodes) {
if (node.group != "default") {
nodes.add(node.group);
}
}
return nodes;
}
// Based on Kratos UI data and a group name, get the full form for that group.
......@@ -212,129 +334,282 @@ function flow_recover() {
// groups.
//
// data: data object as returned form the API
// group: group to render.
function render_form(data, group) {
// Create form
var action = data.ui.action;
var method = data.ui.method;
var form = "<form id='form" + group + "' method='" + method + "' action='" + action + "'>";
for (const node of data.ui.nodes) {
var name = node.attributes.name;
var type = node.attributes.type;
var value = node.attributes.value;
var messages = node.messages;
if (node.group == 'default' || node.group == group) {
var elm = getFormElement(type, name, value, messages);
form += elm;
}
}
form += '</form>';
return form;
// group: group to render
// context: string to specify the context of this form. We need this because
// the Kratos UI data is not sufficient in some cases to decide things like
// texts and button labels.
function render_form(data, group, context) {
// Create form
var action = data.ui.action;
var method = data.ui.method;
var form =
"<form id='form" +
group +
"' method='" +
method +
"' action='" +
action +
"'>";
for (const node of data.ui.nodes) {
if (node.group == "default" || node.group == group) {
// We do not want to show the identifier (email
// address) input *again* in the OIDC form, which is only a
// single "Sign in with ..." button.
if (group == "oidc" && node.attributes.name == "identifier") {
continue;
}
var elm = getFormElement(node, context);
form += elm;
}
}
form += "</form>";
return form;
}
// Check if there are any general messages to show to the user and render them
function render_messages(data) {
var messages = data.ui.messages;
if (messages == []) {
return '';
}
var html = '<ul>';
messages.forEach((message) => {
html += '<li>';
html += message.text;
html += '</li>';
});
html += '</ul>';
return html;
var messages = data.ui.messages;
if (typeof messages == "undefined" || messages == []) {
return "";
}
var html = "";
messages.forEach((message) => {
var type = message.type;
if (type == "error") {
type = "danger";
}
html += "<div class=\"alert alert-" + type + "\">";
html += message.text;
html += "</div>";
});
$("#contentMessages").html(html);
return html;
}
// Return form element based on name, including help text (sub), placeholder etc.
// Kratos give us form names and types and specifies what to render. However
// it does not provide labels or translations. This function returns a HTML
// form element based on the fields provided by Kratos with proper names and
// labels
// labels.
// type: input type, usual "input", "hidden" or "submit". But bootstrap types
// like "email" are also supported
// like "email" are also supported.
// name: name of the field. Used when posting 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 = '';
}
if (typeof messages == 'undefined') {
messages = [];
}
if (name == 'email' || name == 'traits.email') {
return getFormInput(
'email',
name,
value,
'E-mail address',
'Please enter your e-mail address here',
'Please provide your e-mail address. We will send a recovery link to that e-mail address.',
messages,
);
}
if (name == 'traits.username') {
return getFormInput('name', name, value, 'Username', 'Please provide an username', null, messages);
}
if (name == 'traits.name') {
return getFormInput('name', name, value, 'Full name', 'Please provide your full name', null, messages);
}
if (name == 'identifier') {
return getFormInput(
'email',
name,
value,
'E-mail address',
'Please provide your e-mail address to log in',
null,
messages,
);
}
if (name == 'password') {
return getFormInput('password', name, value, 'Password', 'Please provide your password', null, messages);
}
if (type == 'hidden' || name == 'traits.uuid') {
return (
`
function getFormElement(node, context) {
console.log("Getting form element", node);
if (node.attributes.node_type == "script") {
return (
`<script src="` +
node.attributes.src +
`" defer>`
);
}
if (node.type == "img") {
return (
`
<img id="` +
node.attributes.id +
`" src='` +
node.attributes.src +
`'>`
);
}
if (node.type == "text") {
return (
`
<span id="` +
node.attributes.id +
`" class="form-display form-display-` +
node.attributes.text.type +
`">` +
node.attributes.text.text +
`</span>`
);
}
var name = node.attributes.name;
var type = node.attributes.type;
var value = node.attributes.value;
var messages = node.messages;
if (value == undefined) {
value = "";
}
if (typeof messages == "undefined") {
messages = [];
}
if (name == "email" || name == "traits.email") {
var label;
var readonly;
if (context == "settings") {
readonly = true;
label = "Changing your e-mail address is not supported at this point.";
} else {
readonly = false;
label =
"Please provide your e-mail address. If it is registered, we will send a recovery link to that address.";
var email = Cookies.get("recovery_preset_email");
if (email) {
value = email;
Cookies.set("recovery_preset_email", "");
}
}
return getFormInput(
"email",
name,
value,
"E-mail address",
"Please enter your e-mail address here",
label,
messages,
readonly
);
}
if (name == "traits.username") {
return getFormInput(
"name",
name,
value,
"Username",
"Please provide a username",
null,
messages
);
}
if (name == "traits.name") {
return getFormInput(
"name",
name,
value,
"Full name",
"Please provide your full name",
null,
messages
);
}
if (name == "password") {
return getFormInput(
"password",
name,
value,
"Password",
"Please provide your password",
null,
messages
);
}
if (type == "hidden" || name == "traits.uuid") {
return (
`
<input type="hidden" class="form-control" id="` +
name +
`"
name +
`"
name="` +
name +
`" value='` +
value +
`'>`
);
}
if (type == 'submit') {
return (
`<div class="form-group">
name +
`" value='` +
value +
`'>`
);
}
if (name == "identifier") {
return getFormInput(
"email",
name,
value,
"E-mail address",
"Please provide your e-mail address to log in",
null,
messages
);
}
if (name == "totp_code") {
return getFormInput(
"totp_code",
name,
value,
"TOTP code",
"Please enter the code from your TOTP/authenticator app.",
null,
messages,
null,
false
);
}
if (type == "button") {
var label = node.meta.label.text || "Unknown";
// if (name == "webauthn_login_trigger") {
// label = "Confirm with WebAuthn";
// }
const oc = node.attributes.onclick;
return (
`<div class="form-group flex justify-end py-2">
<button type="button" name="` + name + `" onclick='` + oc + `' class="inline-flex h-10 items-center px-2.5 py-1.5 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">` +
label +
`</button>
</div>`
);
}
if (type == "submit") {
var label = "Save";
var justify = "justify-end";
if (name == "totp_unlink") {
label = "Forget saved TOTP device";
} else if (node.group == "totp") {
if (context == "settings") {
label = "Enroll TOTP device";
} else {
label = "Verify";
}
}
if (name == "method" && value == "password") {
if (context == "settings") {
label = "Update password";
} else {
label = "Log in";
}
}
if (context == "recovery") {
label = "Send recovery link";
}
if (node.group == "oidc") {
label = node.meta.label.text;
justify = "justify-center";
}
if (name == "webauthn_remove") {
label = node.meta.label.text;
}
return (
`<div class="form-group flex ` + justify + ` py-2">
<input type="hidden" name="` +
name +
`" value="` +
value +
`">
<button type="submit" class="btn btn-primary">Go!</button>
name +
`" value="` +
value +
`">
<button type="submit" class="inline-flex h-10 items-center px-2.5 py-1.5 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">` +
label +
`</button>
</div>`
);
}
);
}
return getFormInput('input', name, value, name, null, null, messages);
return getFormInput("input", name, value, node.meta.label.text || name, null, null, messages);
}
// Usually called by getFormElement, generic function to generate an
......@@ -346,59 +621,102 @@ function getFormElement(type, name, value, messages) {
// param placeHolder: Label to display in field if empty
// param help: Additional help text, displayed below the field in small font
// param messages: Message about failed input
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';
var element = '<div class="form-group">';
element += '<label for="' + name + '">' + label + '</label>';
element += '<input type="' + type + '" class="form-control" id="' + name + '" name="' + name + '" ';
// messages get appended to help info
if (messages.length) {
for (message in messages) {
console.log('adding message', messages[message]);
help += messages[message]['text'];
}
}
// If we are a password field, add a eye icon to reveal password
if (value) {
element += 'value="' + value + '" ';
}
if (help) {
element += 'aria-describedby="' + nameHelp + '" ';
}
if (placeHolder) {
element += 'placeholder="' + placeHolder + '" ';
}
element += '>';
if (help) {
element +=
`<small id="` +
nameHelp +
`" class="form-text text-muted">` +
help +
`
// param readonly: Whether the input should be readonly (defaults to false)
// param autocomplete: Whether the input should be autocompleted by the browser
// (defaults to true)
function getFormInput(
type,
name,
value,
label,
placeHolder,
help,
messages,
readonly,
autocomplete
) {
if (typeof help == "undefined" || help == null) {
help = "";
}
if (typeof readonly == "undefined") {
readonly = false;
}
if (typeof autocomplete == "undefined") {
autocomplete = true;
}
console.log("Messages: ", messages);
// Id field for help element
var nameHelp = name + "Help";
var element = '<div class="form-group">';
element += '<label for="' + name + '">' + label + "</label>";
element +=
'<input type="' +
type +
'" class="form-control" id="' +
name +
'" name="' +
name +
'" ';
// messages get appended to help info
if (messages.length) {
for (message in messages) {
console.log("adding message", messages[message]);
help += messages[message]["text"];
}
}
// If we are a password field, add a eye icon to reveal password
if (value) {
element += 'value="' + value + '" ';
}
if (help) {
element += 'aria-describedby="' + nameHelp + '" ';
}
if (placeHolder) {
element += 'placeholder="' + placeHolder + '" ';
}
if (readonly) {
element += "readonly ";
}
if (! autocomplete) {
element += "autocomplete=\"off\" ";
}
element += ">";
if (help) {
element +=
`<small id="` +
nameHelp +
`" class="form-text text-muted">` +
help +
`
</small>`;
}
}
element += '</div>';
element += "</div>";
return element;
return element;
}
// $.urlParam get parameters from the URI. Example: id = $.urlParam('id');
$.urlParam = function (name) {
var results = new RegExp('[?&]' + name + '=([^&#]*)').exec(window.location.href);
if (results == null) {
return null;
var results = new RegExp("[?&]" + name + "=([^&#]*)").exec(
window.location.href
);
if (results == null) {
return null;
}
return decodeURI(results[1]) || 0;
};
window.alert = function(message) {
window.console.log("Alert: " + message);
var alertType = "info";
if ((new RegExp("error", "i")).test(message)) {
alertType = "error";
}
return decodeURI(results[1]) || 0;
renderMessage('', message, alertType);
};