[saml] Rework SAML handling

This separates stages more efficiently, and we are e.g. able to
support newer versions of Nextcloud's SAML plugin.
mejoras_instalacion
Evilham 2022-09-22 16:47:42 +02:00
parent ede83e1514
commit fdc3d74958
No known key found for this signature in database
GPG Key ID: AE3EE30D970886BF
22 changed files with 309 additions and 1487 deletions

View File

@ -69,6 +69,7 @@ COPY supervisord.conf /
# Temporary replacement for a real queue # Temporary replacement for a real queue
RUN echo '*/1 * * * * /nc-queue.sh' >> /etc/crontabs/www-data RUN echo '*/1 * * * * /nc-queue.sh' >> /etc/crontabs/www-data
COPY nc-queue.sh / COPY nc-queue.sh /
COPY saml.sh /
ENV NEXTCLOUD_UPDATE=1 ENV NEXTCLOUD_UPDATE=1

View File

@ -33,8 +33,10 @@ services:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- ${SRC_FOLDER}/nextcloud:/var/www/html - ${SRC_FOLDER}/nextcloud:/var/www/html
- ${DATA_FOLDER}/nextcloud:/var/www/html/data - ${DATA_FOLDER}/nextcloud:/var/www/html/data
- ${DATA_FOLDER}/saml/nextcloud:/saml:ro
- ${DATA_FOLDER}/nc-mail-queue:/nc-mail-queue:rw - ${DATA_FOLDER}/nc-mail-queue:/nc-mail-queue:rw
environment: environment:
- DOMAIN=${DOMAIN}
- NEXTCLOUD_ADMIN_USER=${NEXTCLOUD_ADMIN_USER} - NEXTCLOUD_ADMIN_USER=${NEXTCLOUD_ADMIN_USER}
- NEXTCLOUD_ADMIN_PASSWORD=${NEXTCLOUD_ADMIN_PASSWORD} - NEXTCLOUD_ADMIN_PASSWORD=${NEXTCLOUD_ADMIN_PASSWORD}
- POSTGRES_DB=nextcloud - POSTGRES_DB=nextcloud

View File

@ -0,0 +1,40 @@
#!/bin/sh -eu
occ="./occ"
current_nc_saml="$("${occ}" saml:config:get --output=json)"
prov_id="1"
if [ "${current_nc_saml}" = "{}" ] || [ "${current_nc_saml}" = "[]" ]; then
prov_id="$("${occ}" saml:config:create)"
fi
# Gather variables
## When keycloak gets updated, /auth disappears
idp_entityid="https://sso.${DOMAIN}/auth/realms/master"
idp_sso_url="${idp_entityid}/protocol/saml"
## This one has no PEM headers or newlines
idp_x509cert="$(curl -s "${idp_entityid}/protocol/openid-connect/certs" | sed -E 's!.*RS256[^}]*x5c":\["([^"]+)".*!\1!')"
## PEM format
sp_x509cert="$(cat /saml/public.crt)"
## PEM format
sp_privatekey="$(cat /saml/private.key)"
# Actually set up Nextcloud
"${occ}" saml:config:set --no-interaction --no-ansi \
--general-idp0_display_name="SAML Login" \
--general-uid_mapping=username \
--idp-entityId="${idp_entityid}" \
--idp-singleLogoutService.url="${idp_sso_url}" \
--idp-singleSignOnService.url="${idp_sso_url}" \
--idp-x509cert="${idp_x509cert}" \
--security-authnRequestsSigned=1 \
--security-logoutRequestSigned=1 \
--security-logoutResponseSigned=1 \
--security-wantAssertionsSigned=1 \
--security-wantMessagesSigned=1 \
--saml-attribute-mapping-displayName_mapping=displayname \
--saml-attribute-mapping-email_mapping=email \
--saml-attribute-mapping-group_mapping=member \
--saml-attribute-mapping-quota_mapping=quota \
--sp-x509cert="${sp_x509cert}" \
--sp-privateKey="${sp_privatekey}" \
"${prov_id}"
# And set type, else it won't be active
"${occ}" config:app:set user_saml type --value saml

View File

@ -24,6 +24,7 @@ x-volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- ${BUILD_APPS_ROOT_PATH}/docker/wordpress/src/config/php.conf.ini:/usr/local/etc/php/conf.d/conf.ini - ${BUILD_APPS_ROOT_PATH}/docker/wordpress/src/config/php.conf.ini:/usr/local/etc/php/conf.d/conf.ini
- ${SRC_FOLDER}/wordpress:/var/www/html - ${SRC_FOLDER}/wordpress:/var/www/html
- ${DATA_FOLDER}/saml/wordpress:/saml:ro
- ${BUILD_APPS_ROOT_PATH}/docker/wordpress/plugins:/plugins - ${BUILD_APPS_ROOT_PATH}/docker/wordpress/plugins:/plugins
- ${BUILD_APPS_ROOT_PATH}/docker/wordpress/.htaccess:/var/www/html/.htaccess:ro - ${BUILD_APPS_ROOT_PATH}/docker/wordpress/.htaccess:/var/www/html/.htaccess:ro
- ${DATA_FOLDER}/wordpress:/var/www/html/wp-content/uploads - ${DATA_FOLDER}/wordpress:/var/www/html/wp-content/uploads

122
dd-ctl
View File

@ -20,6 +20,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
OPERATION="$1" OPERATION="$1"
shift
# We need docker-compose >= 1.28 to catch configuration variables # We need docker-compose >= 1.28 to catch configuration variables
REQUIRED_DOCKER_COMPOSE_VERSION="1.28" REQUIRED_DOCKER_COMPOSE_VERSION="1.28"
@ -108,7 +109,7 @@ if [ "$OPERATION" != "prerequisites" ]; then
fi fi
fi fi
REPO_BRANCH="${2:-main}" REPO_BRANCH="${1:-main}"
cp dd.conf .env cp dd.conf .env
@ -477,32 +478,85 @@ setup_wordpress(){
} }
setup_keycloak(){ setup_keycloak(){
# configure keycloak: realm and client_scopes echo " --> Setting up SAML: Keycloak realm and client_scopes"
echo " --> Setting up SAML for moodle" docker exec -i dd-sso-admin sh -s <<-EOF
docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 keycloak_config.py" export PYTHONWARNINGS='ignore:Unverified HTTPS request'
cd /admin/saml_scripts/ && python3 keycloak_config.py
EOF
} }
saml_certificates(){ saml_generate_certificates(){
saml_dir="${DATA_FOLDER}/saml"
mkdir -p "${saml_dir}/public"
# Moodle generates its own certificate earlier, only NC and Wp
for saml_info in nextcloud:82 wordpress:33; do
saml_sp="$(echo "${saml_info}" | cut -d ':' -f 1)"
sp_uid="$(echo "${saml_info}" | cut -d ':' -f 2)"
sp_dir="${saml_dir}/${saml_sp}"
mkdir -p "${sp_dir}"
C=CA
L=Barcelona
O=localdomain
CN_CA=$O
# Generate certificate
echo " --> Generating SAML certificates for SP: ${saml_sp}"
openssl req -nodes -new -x509 \
-keyout "${sp_dir}/private.key" \
-out "${sp_dir}/public.crt" \
-subj "/C=$C/L=$L/O=$O/CN=$CN_CA" -days 3650
# Fix permissions
chown -R "${sp_uid}:${sp_uid}" "${sp_dir}"
chmod 0550 "${sp_dir}"
# Propagate public part
cp "${sp_dir}/public.crt" "${saml_dir}/public/${saml_sp}.crt"
done
# TODO: Rework wordpress_saml.py so we can get rid of this
cp "${saml_dir}/wordpress/private.key" "${saml_dir}/public/wordpress.key"
chmod 0555 "${saml_dir}/public"
find "${saml_dir}/public" -type f -exec chmod 0444 '{}' '+'
}
saml_register_sps_with_idp(){
wait_for_moodle wait_for_moodle
echo " --> Setting up SAML for moodle"
docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 moodle_saml.py"
docker exec -ti dd-apps-moodle php7 admin/cli/purge_caches.php
# CERTIFICATES FOR SAML # Setup SAML for each SP
echo " --> Generating certificates for nextcloud and wordpress" for saml_sp in moodle nextcloud wordpress email; do
docker exec -ti dd-sso-admin /bin/sh -c "/admin/generate_certificates.sh" echo " --> Registering SAML SP '${saml_sp}' in IDP"
docker exec -i dd-sso-admin sh -s <<-EOF
export PYTHONWARNINGS='ignore:Unverified HTTPS request'
cd /admin/saml_scripts/ && python3 ${saml_sp}_saml.py
EOF
test $? == 0 || printf "\tError setting up SAML for %s...\n" "${saml_sp}" >&2
done
# SAML PLUGIN NEXTCLOUD # Purge cache for moodle
echo " --> Setting up SAML for nextcloud" docker exec -i dd-apps-moodle php7 admin/cli/purge_caches.php
docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 nextcloud_saml.py" }
# SAML PLUGIN WORDPRESS saml_setup_idp_in_sps(){
echo " --> Setting up SAML for wordpress" # We need to support this for newer Nextcloud versions
docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 wordpress_saml.py" saml_info="dd-apps-nextcloud-app:82"
saml_sp="$(echo "${saml_info}" | cut -d ':' -f 1)"
sp_uid="$(echo "${saml_info}" | cut -d ':' -f 2)"
echo " --> Setting up SAML IDP in ${saml_sp}"
docker exec -i -u "${sp_uid}" "${saml_sp}" /saml.sh
}
# SAML PLUGIN EMAIL saml(){
echo " --> Setting up SAML for email" if [ "${1:-}" != "--no-up" ]; then
docker exec -ti dd-sso-admin sh -c "export PYTHONWARNINGS='ignore:Unverified HTTPS request' && cd /admin/saml_scripts/ && python3 email_saml.py" up
wait_for_moodle
fi
# Ensure realm is OK and write public IDP cert
setup_keycloak
# Make sure each SP has its own certs and fix permissions if needed
saml_generate_certificates
# Register all SPs with the IDP
saml_register_sps_with_idp
# Setup IDP in each SP
saml_setup_idp_in_sps
} }
wait_for_moodle(){ wait_for_moodle(){
@ -519,9 +573,9 @@ wait_for_moodle(){
} }
upgrade_moodle(){ upgrade_moodle(){
docker exec -ti dd-apps-moodle php7 admin/cli/maintenance.php --enable docker exec -i dd-apps-moodle php7 admin/cli/maintenance.php --enable
docker exec -ti dd-apps-moodle php7 admin/cli/upgrade.php --non-interactive --allow-unstable docker exec -i dd-apps-moodle php7 admin/cli/upgrade.php --non-interactive --allow-unstable
docker exec -ti dd-apps-moodle php7 admin/cli/maintenance.php --disable docker exec -i dd-apps-moodle php7 admin/cli/maintenance.php --disable
} }
extras_adminer(){ extras_adminer(){
@ -649,7 +703,7 @@ upgrade_plugins_moodle(){
cp -R /tmp/moodle/* "$SRC_FOLDER/moodle/" cp -R /tmp/moodle/* "$SRC_FOLDER/moodle/"
rm -rf /tmp/moodle rm -rf /tmp/moodle
docker exec -ti dd-apps-moodle php7 admin/cli/purge_caches.php docker exec -i dd-apps-moodle php7 admin/cli/purge_caches.php
} }
upgrade_plugins_nextcloud(){ upgrade_plugins_nextcloud(){
@ -689,11 +743,11 @@ upgrade_plugins_wp(){
} }
update_logos_and_menu(){ update_logos_and_menu(){
# docker exec -ti dd-sso-keycloak sh -c "/opt/jboss/keycloak/bin/jboss-cli.sh --connect --command='/subsystem=keycloak-server/theme=defaults/:write-attribute(name=cacheThemes,value=false)'" # docker exec -i dd-sso-keycloak sh -c "/opt/jboss/keycloak/bin/jboss-cli.sh --connect --command='/subsystem=keycloak-server/theme=defaults/:write-attribute(name=cacheThemes,value=false)'"
# docker exec -ti dd-sso-keycloak sh -c "/opt/jboss/keycloak/bin/jboss-cli.sh --connect --command='/subsystem=keycloak-server/theme=defaults/:write-attribute(name=cacheTemplates,value=false)'" # docker exec -i dd-sso-keycloak sh -c "/opt/jboss/keycloak/bin/jboss-cli.sh --connect --command='/subsystem=keycloak-server/theme=defaults/:write-attribute(name=cacheTemplates,value=false)'"
# docker exec -ti dd-sso-keycloak sh -c "/opt/jboss/keycloak/bin/jboss-cli.sh --connect --command='/subsystem=keycloak-server/theme=defaults/:write-attribute(name=staticMaxAge,value=-1)'" # docker exec -i dd-sso-keycloak sh -c "/opt/jboss/keycloak/bin/jboss-cli.sh --connect --command='/subsystem=keycloak-server/theme=defaults/:write-attribute(name=staticMaxAge,value=-1)'"
# docker exec -ti dd-sso-keycloak sh -c "/opt/jboss/keycloak/bin/jboss-cli.sh --connect --command='reload'" # docker exec -i dd-sso-keycloak sh -c "/opt/jboss/keycloak/bin/jboss-cli.sh --connect --command='reload'"
docker exec -ti --user root dd-sso-keycloak sh -c 'rm -rf /opt/jboss/keycloak/standalone/tmp/kc-gzip-cache/*' docker exec -i --user root dd-sso-keycloak sh -c 'rm -rf /opt/jboss/keycloak/standalone/tmp/kc-gzip-cache/*'
docker-compose build dd-sso-api && docker-compose up -d dd-sso-api docker-compose build dd-sso-api && docker-compose up -d dd-sso-api
configure_nextcloud_logo configure_nextcloud_logo
} }
@ -817,8 +871,7 @@ case "$OPERATION" in
setup_moodle setup_moodle
setup_wordpress setup_wordpress
setup_keycloak saml --no-up
saml_certificates
cat <<-EOF cat <<-EOF
@ -884,16 +937,13 @@ case "$OPERATION" in
docker restart dd-sso-api docker restart dd-sso-api
;; ;;
saml) saml)
up saml "$@"
wait_for_moodle
setup_keycloak
saml_certificates
;; ;;
securize) securize)
securize securize
;; ;;
setconf) setconf)
setconf "$2" "$3" setconf "$@"
;; ;;
up) up)
up up

View File

@ -238,9 +238,6 @@ sleep 30
./dd-ctl down ./dd-ctl down
./dd-ctl up ./dd-ctl up
## Ensure SAML is set up (it errors always)
./dd-ctl saml || true
# Address Moodle custom settings, plugins and registration # Address Moodle custom settings, plugins and registration
NEXTCLOUD_ADMIN_USER="$(grep -E '^NEXTCLOUD_ADMIN_USER=' dd.conf | cut -d = -f 2-)" NEXTCLOUD_ADMIN_USER="$(grep -E '^NEXTCLOUD_ADMIN_USER=' dd.conf | cut -d = -f 2-)"
NEXTCLOUD_ADMIN_PASSWORD="$(grep -E '^NEXTCLOUD_ADMIN_PASSWORD=' dd.conf | cut -d = -f 2-)" NEXTCLOUD_ADMIN_PASSWORD="$(grep -E '^NEXTCLOUD_ADMIN_PASSWORD=' dd.conf | cut -d = -f 2-)"
@ -256,8 +253,9 @@ php theme/cbe/postinstall.php \
--contactemail="${DD_LETSENCRYPT_EMAIL}" --contactemail="${DD_LETSENCRYPT_EMAIL}"
php admin/cli/maintenance.php --disable php admin/cli/maintenance.php --disable
EOF EOF
# And fix moodle SAML with the admin
docker exec dd-sso-admin python3 /admin/saml_scripts/moodle_saml.py ## Ensure SAML is set up
./dd-ctl saml --no-up
# Proceed further with manual steps # Proceed further with manual steps
cat <<EOF | tee -a "${DDLOG}" cat <<EOF | tee -a "${DDLOG}"

View File

@ -22,7 +22,7 @@
# We possibly need to fix bad old permissions # We possibly need to fix bad old permissions
chown -R www-data:www-data \ chown -R www-data:www-data \
/admin/custom \ /admin/custom \
/admin/moodledata/saml2 /admin/saml_certs \ /admin/moodledata/saml2 \
"${DATA_FOLDER}" \ "${DATA_FOLDER}" \
"${LEGAL_PATH}" \ "${LEGAL_PATH}" \
"${NC_MAIL_QUEUE_FOLDER}" "${NC_MAIL_QUEUE_FOLDER}"

View File

@ -20,13 +20,14 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
import mysql.connector import mysql.connector
import mysql.connector.cursor
from typing import List, Tuple from typing import List, Tuple
class Mysql: class Mysql:
# TODO: Fix this whole class # TODO: Fix this whole class
cur : mysql.connector.MySQLCursor cur : mysql.connector.cursor.MySQLCursor
conn : mysql.connector.MySQLConnection conn : mysql.connector.MySQLConnection
def __init__(self, host : str, database : str, user : str, password : str) -> None: def __init__(self, host : str, database : str, user : str, password : str) -> None:
self.conn = mysql.connector.connect( self.conn = mysql.connector.connect(

View File

@ -1,29 +0,0 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
#
# This file is part of DD
#
# DD is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with DD. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: AGPL-3.0-or-later
cd /admin/saml_certs
C=CA
L=Barcelona
O=localdomain
CN_CA=$O
CN_HOST=*.$O
OU=$O
openssl req -nodes -new -x509 -keyout private.key -out public.cert -subj "/C=$C/L=$L/O=$O/CN=$CN_CA" -days 3650
cd /admin
echo "Now run the python nextcloud and wordpress scripts"

View File

@ -1,164 +0,0 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
#
# This file is part of DD
#
# DD is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with DD. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: AGPL-3.0-or-later
import json
import logging as log
import os
import pprint
import random
import string
import time
import traceback
from datetime import datetime, timedelta
import psycopg2
import yaml
from admin.lib.keycloak_client import KeycloakClient
from admin.lib.postgres import Postgres
app = {}
app["config"] = {}
class NextcloudSaml:
def __init__(self):
self.url = "http://dd-sso-keycloak:8080/auth/"
self.username = os.environ["KEYCLOAK_USER"]
self.password = os.environ["KEYCLOAK_PASSWORD"]
self.realm = "master"
self.verify = True
ready = False
while not ready:
try:
self.pg = Postgres(
"dd-apps-postgresql",
"nextcloud",
os.environ["NEXTCLOUD_POSTGRES_USER"],
os.environ["NEXTCLOUD_POSTGRES_PASSWORD"],
)
ready = True
except:
log.warning("Could not connect to nextcloud database. Retrying...")
time.sleep(2)
log.info("Connected to nextcloud database.")
ready = False
while not ready:
try:
with open(os.path.join("./saml_certs/public.cert"), "r") as crt:
app["config"]["PUBLIC_CERT"] = crt.read()
ready = True
except IOError:
log.warning(
"Could not get public certificate to be used in nextcloud. Retrying..."
)
log.warning(
" You should generate them: /admin/saml_certs # openssl req -nodes -new -x509 -keyout private.key -out public.cert"
)
time.sleep(2)
except:
log.error(traceback.format_exc())
log.info("Got moodle srt certificate to be used in nextcloud.")
ready = False
while not ready:
try:
with open(os.path.join("./saml_certs/private.key"), "r") as pem:
app["config"]["PRIVATE_KEY"] = pem.read()
ready = True
except IOError:
log.warning(
"Could not get private key to be used in nextcloud. Retrying..."
)
log.warning(
" You should generate them: /admin/saml_certs # openssl req -nodes -new -x509 -keyout private.key -out public.cert"
)
time.sleep(2)
log.info("Got moodle pem certificate to be used in nextcloud.")
# ## This seems related to the fact that the certificate generated the first time does'nt work.
# ## And when regenerating the certificate de privatekeypass seems not to be used and instead it
# ## will use always this code as filename: 0f635d0e0f3874fff8b581c132e6c7a7
# ## As this bug I'm not able to solve, the process is:
# ## 1.- Bring up moodle and regenerate certificates on saml2 plugin in plugins-authentication
# ## 2.- Execute this script
# ## 3.- Cleanup all caches in moodle (Development tab)
# # with open(os.path.join("./moodledata/saml2/"+os.environ['NEXTCLOUD_SAML_PRIVATEKEYPASS'].replace("moodle."+os.environ['DOMAIN'],'')+'.idp.xml'),"w") as xml:
# # xml.write(self.parse_idp_metadata())
# with open(os.path.join("./moodledata/saml2/0f635d0e0f3874fff8b581c132e6c7a7.idp.xml"),"w") as xml:
# xml.write(self.parse_idp_metadata())
try:
self.reset_saml_certs()
except:
print("Error resetting saml on nextcloud")
try:
self.set_nextcloud_saml_plugin_certs()
except:
log.error(traceback.format_exc())
print("Error adding saml on nextcloud")
def connect(self):
self.keycloak = KeycloakClient(
url=self.url,
username=self.username,
password=self.password,
realm=self.realm,
verify=self.verify,
)
# def activate_saml_plugin(self):
# ## After you need to purge moodle caches: /var/www/html # php admin/cli/purge_caches.php
# return self.pg.update("""UPDATE "mdl_config" SET value = 'email,saml2' WHERE "name" = 'auth'""")
# def get_privatekey_pass(self):
# return self.pg.select("""SELECT * FROM "mdl_config" WHERE "name" = 'siteidentifier'""")[0][2]
def parse_idp_cert(self):
self.connect()
rsa = self.keycloak.get_server_rsa_key()
self.keycloak = None
return rsa["certificate"]
def set_nextcloud_saml_plugin_certs(self):
self.pg.update(
"""INSERT INTO "oc_appconfig" ("appid", "configkey", "configvalue") VALUES
('user_saml', 'sp-privateKey', '%s'),
('user_saml', 'idp-x509cert', '%s'),
('user_saml', 'sp-x509cert', '%s');"""
% (
app["config"]["PRIVATE_KEY"],
self.parse_idp_cert(),
app["config"]["PUBLIC_CERT"],
)
)
def reset_saml_certs(self):
cfg_list = ["sp-privateKey", "idp-x509cert", "sp-x509cert"]
for cfg in cfg_list:
self.pg.update(
"""DELETE FROM "oc_appconfig" WHERE appid = 'user_saml' AND configkey = '%s'"""
% (cfg)
)
n = NextcloudSaml()

View File

@ -1,70 +0,0 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
#
# This file is part of DD
#
# DD is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with DD. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: AGPL-3.0-or-later
import json
import logging as log
import pprint
import time
import traceback
from datetime import datetime, timedelta
import psycopg2
import yaml
class Postgres:
def __init__(self, host, database, user, password):
self.conn = psycopg2.connect(
host=host, database=database, user=user, password=password
)
# def __del__(self):
# self.cur.close()
# self.conn.close()
def select(self, sql):
self.cur = self.conn.cursor()
self.cur.execute(sql)
data = self.cur.fetchall()
self.cur.close()
return data
def update(self, sql):
self.cur = self.conn.cursor()
self.cur.execute(sql)
self.conn.commit()
self.cur.close()
# return self.cur.fetchall()
def select_with_headers(self, sql):
self.cur = self.conn.cursor()
self.cur.execute(sql)
data = self.cur.fetchall()
fields = [a.name for a in self.cur.description]
self.cur.close()
return (fields, data)
# def update_moodle_saml_plugin(self):
# plugin[('idpmetadata', '<md:EntitiesDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Name="urn:keycloak"><md:EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="https://sso.'+app.config['DOMAIN']+'/auth/realms/master"><md:IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><md:KeyDescriptor use="signing"><ds:KeyInfo><ds:KeyName>NrtA5ynG0htowP3SXw7dBJRIAMxn-1PwuuXwOwNhlRw</ds:KeyName><ds:X509Data><ds:X509Certificate>MIICmzCCAYMCBgF5jb0RCTANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjEwNTIxMDcwMjI4WhcNMzEwNTIxMDcwNDA4WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCI8xh/C0+frz3kgWiUbziTDls71R2YiXLSVE+bw7gbEgZUGCLhoEI679azMtIxmnzM/snIX+yTb12+XoYkgbiLTMPQfnH+Kiab6g3HL3KPfhqS+yWkFxOoCp6Ibmp7yPlVWuHH+MBfO8OBr/r8Ao7heFbuzjiLd1KG67rcoaxfDgMuBoEomg1bgEjFgHaQIrSC6OZzH0h987/arqufZXeXlfyiqScMPUi+u5IpDWSwz06UKP0k8mxzNSlpZ93CKOUSsV0SMLxqg7FQ3SGiOk577bGW9o9BDTkkmSo3Up6smc0LzwvvUwuNd0B1irGkWZFQN9OXJnJYf1InEebIMtmPAgMBAAEwDQYJKoZIhvcNAQELBQADggEBADM34+qEGeBQ22luphVTuVJtGxcbxLx7DfsT0QfJD/OuxTTbNAa1VRyarb5juIAkqdj4y2quZna9ZXLecVo4RkwpzPoKoAkYA8b+kHnWqEwJi9iPrDvKb+GR0bBkLPN49YxIZ8IdKX/PRa3yuLHe+loiNsCaS/2ZK2KO46COsqU4QX1iVhF9kWphNLybjNAX45B6cJLsa1g0vXLdm3kv3SB4I2fErFVaOoDtFIjttoYlXdpUiThkPXBfr7N67P3dZHaS4tjJh+IZ8I6TINpcsH8dBkUhzYEIPHCePwSiC1w6WDBLNDuKt1mj1CZrLq+1x+Yhrs+QNRheEKGi89HZ8N0=</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor><md:ArtifactResolutionService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml/resolve" index="0"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/></md:IDPSSODescriptor></md:EntityDescriptor></md:EntitiesDescriptor>')]
# pg_update = """UPDATE mdl_config_plugins set title = %s where plugin = auth_saml2 and name ="""
# cursor.execute(pg_update, (title, bookid))
# connection.commit()
# count = cursor.rowcount
# print(count, "Successfully Updated!")

View File

@ -18,95 +18,18 @@
# #
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
import logging as log
import os import os
import time from typing import Any, Dict
import traceback
from typing import Any, Dict, Optional
from lib.keycloak_client import KeycloakClient from saml_service import SamlService
try:
# Python 3.9+
from functools import cache # type: ignore # Currently targetting 3.8
except ImportError:
# Up to python 3.8
from functools import cached_property as cache
app: Dict[str, Dict[str, str]] = {}
app["config"] = {}
class SamlService(object):
"""
Generic class to manage a SAML service on keycloak
"""
keycloak: KeycloakClient
domain: str = os.environ["DOMAIN"]
def __init__(self):
self.keycloak = KeycloakClient()
@cache
def public_cert(self) -> str:
"""
Read the public SAML certificate as used by keycloak
"""
ready = False
basepath = os.path.dirname(__file__)
while not ready:
# TODO: Check why they were using a loop
try:
with open(
os.path.abspath(
os.path.join(basepath, "../saml_certs/public.cert")
),
"r",
) as crt:
app["config"]["PUBLIC_CERT"] = crt.read()
ready = True
except IOError:
log.warning("Could not get public certificate for SAML. Retrying...")
log.warning(
" You should generate them: /admin/saml_certs # openssl req -nodes -new -x509 -keyout private.key -out public.cert"
)
time.sleep(2)
except:
log.error(traceback.format_exc())
log.info("Got public SAML certificate")
return app["config"]["PUBLIC_CERT"]
def get_client(self, client_id: str) -> Any:
# TODO: merge with keycloak_config.py
self.keycloak.connect()
k = self.keycloak.keycloak_admin
clients = k.get_clients()
client = next(filter(lambda c: c["clientId"] == client_id, clients), None)
return (k, client)
def set_client(self, client_id: str, client_overrides: Dict[str, Any]) -> str:
(k, client) = self.get_client(client_id)
if client is None:
client_uid = k.create_client(client_overrides)
else:
client_uid = client["id"]
k.update_client(client_uid, client_overrides)
return client_id
def configure(self) -> None:
pass
class EmailSaml(SamlService): class EmailSaml(SamlService):
client_name: str = "correu"
client_description: str = "Client for the DD-managed email service" client_description: str = "Client for the DD-managed email service"
email_domain: str email_domain: str
def __init__(self, email_domain: str, enabled: bool = False): def __init__(self, email_domain: str):
super(EmailSaml, self).__init__() super(EmailSaml, self).__init__(client_name="correu")
self.email_domain = email_domain self.email_domain = email_domain
@property @property
@ -182,6 +105,7 @@ class EmailSaml(SamlService):
if __name__ == "__main__": if __name__ == "__main__":
import logging as log
email_domain = os.environ.get("MANAGED_EMAIL_DOMAIN", "") email_domain = os.environ.get("MANAGED_EMAIL_DOMAIN", "")
log.info("Configuring SAML client for Email") log.info("Configuring SAML client for Email")
EmailSaml(email_domain).configure() EmailSaml(email_domain=email_domain).configure()

View File

@ -23,7 +23,7 @@ import logging as log
import os import os
import os.path import os.path
from lib.keycloak_client import KeycloakClient from admin.lib.keycloak_client import KeycloakClient
class KeycloakConfig: class KeycloakConfig:

View File

@ -1,534 +0,0 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
#
# This file is part of DD
#
# DD is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with DD. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: AGPL-3.0-or-later
import json
import logging as log
import os
import time
import traceback
from datetime import datetime, timedelta
from pprint import pprint
import yaml
from jinja2 import Environment, FileSystemLoader
from keycloak import KeycloakAdmin
from .postgres import Postgres
# from admin import app
class KeycloakClient:
"""https://www.keycloak.org/docs-api/13.0/rest-api/index.html
https://github.com/marcospereirampj/python-keycloak
https://gist.github.com/kaqfa/99829941121188d7cef8271f93f52f1f
"""
def __init__(
self,
url="http://dd-sso-keycloak:8080/auth/",
username=os.environ["KEYCLOAK_USER"],
password=os.environ["KEYCLOAK_PASSWORD"],
realm="master",
verify=True,
):
self.url = url
self.username = username
self.password = password
self.realm = realm
self.verify = verify
self.keycloak_pg = Postgres(
"dd-apps-postgresql",
"keycloak",
os.environ["KEYCLOAK_DB_USER"],
os.environ["KEYCLOAK_DB_PASSWORD"],
)
def connect(self):
self.keycloak_admin = KeycloakAdmin(
server_url=self.url,
username=self.username,
password=self.password,
realm_name=self.realm,
verify=self.verify,
)
# from keycloak import KeycloakAdmin
# keycloak_admin = KeycloakAdmin(server_url="http://dd-sso-keycloak:8080/auth/",username="admin",password="keycloakkeycloak",realm_name="master",verify=False)
######## Example create group and subgroup
# try:
# self.add_group('level1')
# except:
# self.delete_group(self.get_group('/level1')['id'])
# self.add_group('level1')
# self.add_group('level2',parent=self.get_group('/level1')['id'])
# pprint(self.get_groups())
######## Example roles
# try:
# self.add_role('superman')
# except:
# self.delete_role('superman')
# self.add_role('superman')
# pprint(self.get_roles())
""" USERS """
def get_user_id(self, username):
self.connect()
return self.keycloak_admin.get_user_id(username)
def get_users(self):
self.connect()
return self.keycloak_admin.get_users({})
def get_users_with_groups_and_roles(self):
q = """select u.id, u.username, u.email, u.first_name, u.last_name, u.realm_id, u.enabled, ua.value as quota
,json_agg(g."id") as group, json_agg(g_parent."name") as group_parent1, json_agg(g_parent2."name") as group_parent2
,json_agg(r.name) as role
from user_entity as u
left join user_attribute as ua on ua.user_id=u.id and ua.name = 'quota'
left join user_group_membership as ugm on ugm.user_id = u.id
left join keycloak_group as g on g.id = ugm.group_id
left join keycloak_group as g_parent on g.parent_group = g_parent.id
left join keycloak_group as g_parent2 on g_parent.parent_group = g_parent2.id
left join user_role_mapping as rm on rm.user_id = u.id
left join keycloak_role as r on r.id = rm.role_id
group by u.id,u.username,u.email,u.first_name,u.last_name, u.realm_id, u.enabled, ua.value
order by u.username"""
(headers, users) = self.keycloak_pg.select_with_headers(q)
users_with_lists = [
list(l[:-4])
+ ([[]] if l[-4] == [None] else [list(set(l[-4]))])
+ ([[]] if l[-3] == [None] else [list(set(l[-3]))])
+ ([[]] if l[-3] == [None] else [list(set(l[-2]))])
+ ([[]] if l[-1] == [None] else [list(set(l[-1]))])
for l in users
]
users_with_lists = [
list(l[:-4])
+ ([[]] if l[-4] == [None] else [list(set(l[-4]))])
+ ([[]] if l[-3] == [None] else [list(set(l[-3]))])
+ ([[]] if l[-3] == [None] else [list(set(l[-2]))])
+ ([[]] if l[-1] == [None] else [list(set(l[-1]))])
for l in users_with_lists
]
list_dict_users = [dict(zip(headers, r)) for r in users_with_lists]
# self.connect()
# groups = self.keycloak_admin.get_groups()
# for user in list_dict_users:
# new_user_groups = []
# for group_id in user['group']:
# found = [g for g in groups if g['id'] == group_id][0]
# new_user_groups.append({'id':found['id'],
# 'name':found['name'],
# 'path':found['path']})
# user['group']=new_user_groups
return list_dict_users
def getparent(self, group_id, data):
# Recursively get full path from any group_id in the tree
path = ""
for item in data:
if group_id == item[0]:
path = self.getparent(item[2], data)
path = f"{path}/{item[1]}"
return path
def get_group_path(self, group_id):
# Get full path using getparent recursive func
# RETURNS: String with full path
q = """SELECT * FROM keycloak_group"""
groups = self.keycloak_pg.select(q)
return self.getparent(group_id, groups)
def get_user_groups_paths(self, user_id):
# Get full paths for user grups
# RETURNS list of paths
q = """SELECT group_id FROM user_group_membership WHERE user_id = '%s'""" % (
user_id
)
user_group_ids = self.keycloak_pg.select(q)
paths = []
for g in user_group_ids:
paths.append(self.get_group_path(g[0]))
return paths
## Too slow. Used the direct postgres
# def get_users_with_groups_and_roles(self):
# self.connect()
# users=self.keycloak_admin.get_users({})
# for user in users:
# user['groups']=[g['path'] for g in self.keycloak_admin.get_user_groups(user_id=user['id'])]
# user['roles']= [r['name'] for r in self.keycloak_admin.get_realm_roles_of_user(user_id=user['id'])]
# return users
def add_user(
self,
username,
first,
last,
email,
password,
group=False,
temporary=True,
enabled=True,
):
# RETURNS string with keycloak user id (the main id in this app)
self.connect()
username = username.lower()
try:
uid = self.keycloak_admin.create_user(
{
"email": email,
"username": username,
"enabled": enabled,
"firstName": first,
"lastName": last,
"credentials": [
{"type": "password", "value": password, "temporary": temporary}
],
}
)
except:
log.error(traceback.format_exc())
if group:
path = "/" + group if group[1:] != "/" else group
try:
gid = self.keycloak_admin.get_group_by_path(
path=path, search_in_subgroups=False
)["id"]
except:
self.keycloak_admin.create_group({"name": group})
gid = self.keycloak_admin.get_group_by_path(path)["id"]
self.keycloak_admin.group_user_add(uid, gid)
return uid
def update_user_pwd(self, user_id, password, temporary=True):
# Updates
payload = {
"credentials": [
{"type": "password", "value": password, "temporary": temporary}
]
}
self.connect()
return self.keycloak_admin.update_user(user_id, payload)
def user_update(self, user_id, enabled, email, first, last, groups=[], roles=[]):
## NOTE: Roles didn't seem to be updated/added. Also not confident with groups
# Updates
payload = {
"enabled": enabled,
"email": email,
"firstName": first,
"lastName": last,
"groups": groups,
"realmRoles": roles,
}
self.connect()
return self.keycloak_admin.update_user(user_id, payload)
def user_enable(self, user_id):
payload = {"enabled": True}
self.connect()
return self.keycloak_admin.update_user(user_id, payload)
def user_disable(self, user_id):
payload = {"enabled": False}
self.connect()
return self.keycloak_admin.update_user(user_id, payload)
def group_user_remove(self, user_id, group_id):
self.connect()
return self.keycloak_admin.group_user_remove(user_id, group_id)
# def add_user_role(self,user_id,role_id):
# self.connect()
# return self.keycloak_admin.assign_role(client_id=client_id, user_id=user_id, role_id=role_id, role_name="test")
def remove_user_realm_roles(self, user_id, roles):
self.connect()
roles = [
r
for r in self.get_user_realm_roles(user_id)
if r["name"] in ["admin", "manager", "teacher", "student"]
]
return self.keycloak_admin.delete_realm_roles_of_user(user_id, roles)
def delete_user(self, userid):
self.connect()
return self.keycloak_admin.delete_user(user_id=userid)
def get_user_groups(self, userid):
self.connect()
return self.keycloak_admin.get_user_groups(user_id=userid)
def get_user_realm_roles(self, userid):
self.connect()
return self.keycloak_admin.get_realm_roles_of_user(user_id=userid)
def add_user_client_role(self, client_id, user_id, role_id, role_name):
self.connect()
return self.keycloak_admin.assign_client_role(
client_id=client_id, user_id=user_id, role_id=role_id, role_name="test"
)
## GROUPS
def get_all_groups(self):
## RETURNS ONLY MAIN GROUPS WITH NESTED subGroups list
self.connect()
return self.keycloak_admin.get_groups()
def get_recursive_groups(self, l_groups, l=[]):
for d_group in l_groups:
d = {}
for key, value in d_group.items():
if key == "subGroups":
self.get_recursive_groups(value, l)
else:
d[key] = value
l.append(d)
return l
def get_groups(self, with_subgroups=True):
## RETURNS ALL GROUPS in root list
self.connect()
groups = self.keycloak_admin.get_groups()
return self.get_recursive_groups(groups)
subgroups = []
subgroups1 = []
# This needs to be recursive function
if with_subgroups:
for group in groups:
if len(group["subGroups"]):
for sg in group["subGroups"]:
subgroups.append(sg)
# for sgroup in subgroups:
# if len(sgroup['subGroups']):
# for sg1 in sgroup['subGroups']:
# subgroups1.append(sg1)
return groups + subgroups + subgroups1
def get_group_by_id(self, group_id):
self.connect()
return self.keycloak_admin.get_group(group_id=group_id)
def get_group_by_path(self, path, recursive=True):
self.connect()
return self.keycloak_admin.get_group_by_path(
path=path, search_in_subgroups=recursive
)
def add_group(self, name, parent=None, skip_exists=False):
self.connect()
if parent != None:
parent = self.get_group_by_path(parent)["id"]
return self.keycloak_admin.create_group({"name": name}, parent=parent)
def delete_group(self, group_id):
self.connect()
return self.keycloak_admin.delete_group(group_id=group_id)
def group_user_add(self, user_id, group_id):
self.connect()
return self.keycloak_admin.group_user_add(user_id, group_id)
def add_group_tree(self, path):
parts = path.split("/")
parent_path = "/"
for i in range(1, len(parts)):
if i == 1:
try:
self.add_group(parts[i], None, skip_exists=True)
except:
log.warning("KEYCLOAK: Group :" + parts[i] + " already exists.")
parent_path = parent_path + parts[i]
else:
try:
self.add_group(parts[i], parent_path, skip_exists=True)
except:
log.warning("KEYCLOAK: Group :" + parts[i] + " already exists.")
parent_path = parent_path + parts[i]
# parts=path.split('/')
# parent_path=None
# for i in range(1,len(parts)):
# # print('Adding group name '+parts[i]+' with parent path '+str(parent_path))
# try:
# self.add_group(parts[i],parent_path,skip_exists=True)
# except:
# if parent_path==None:
# parent_path='/'+parts[i]
# else:
# parent_path=self.get_group_by_path(parent_path)['path']
# parent_path=parent_path+'/'+parts[i]
# continue
# if parent_path==None:
# parent_path='/'+parts[i]
# else:
# parent_path=parent_path+'/'+parts[i]
# try:
# if i == 1: parent_id=self.add_group(parts[i])
# except:
# # Main already exists?? What a fail!
# parent_id=self.get_group(parent_id)['id']
# continue
# self.add_group(parts[i],parent_id)
def add_user_with_groups_and_role(
self, username, first, last, email, password, role, groups
):
## Add user
uid = self.add_user(username, first, last, email, password)
## Add user to role
log.info("User uid: " + str(uid) + " role: " + str(role))
try:
therole = role[0]
except:
therole = ""
log.info(self.assign_realm_roles(uid, role))
## Create groups in user
for g in groups:
log.warning("Creating keycloak group: " + g)
parts = g.split("/")
parent_path = None
for i in range(1, len(parts)):
# parent_id=None if parent_path==None else self.get_group(parent_path)['id']
try:
self.add_group(parts[i], parent_path, skip_exists=True)
except:
log.warning(
"Group "
+ str(parent_path)
+ " already exists. Skipping creation"
)
pass
if parent_path is None:
thepath = "/" + parts[i]
else:
thepath = parent_path + "/" + parts[i]
if thepath == "/":
log.warning(
"Not adding the user "
+ username
+ " to any group as does not have any..."
)
continue
gid = self.get_group_by_path(path=thepath)["id"]
log.warning(
"Adding "
+ username
+ " with uuid: "
+ uid
+ " to group "
+ g
+ " with uuid: "
+ gid
)
self.keycloak_admin.group_user_add(uid, gid)
if parent_path == None:
parent_path = ""
parent_path = parent_path + "/" + parts[i]
# self.group_user_add(uid,gid)
## ROLES
def get_roles(self):
self.connect()
return self.keycloak_admin.get_realm_roles()
def get_role(self, name):
self.connect()
return self.keycloak_admin.get_realm_role(name)
def add_role(self, name, description=""):
self.connect()
return self.keycloak_admin.create_realm_role(
{"name": name, "description": description}
)
def delete_role(self, name):
self.connect()
return self.keycloak_admin.delete_realm_role(name)
## CLIENTS
def get_client_roles(self, client_id):
self.connect()
return self.keycloak_admin.get_client_roles(client_id=client_id)
def add_client_role(self, client_id, name, description=""):
self.connect()
return self.keycloak_admin.create_client_role(
client_id, {"name": name, "description": description, "clientRole": True}
)
## SYSTEM
def get_server_info(self):
self.connect()
return self.keycloak_admin.get_server_info()
def get_server_clients(self):
self.connect()
return self.keycloak_admin.get_clients()
def get_server_rsa_key(self):
self.connect()
rsa_key = [
k for k in self.keycloak_admin.get_keys()["keys"] if k["type"] == "RSA"
][0]
return {"name": rsa_key["kid"], "certificate": rsa_key["certificate"]}
## REALM
def assign_realm_roles(self, user_id, role):
self.connect()
try:
role = [
r for r in self.keycloak_admin.get_realm_roles() if r["name"] == role
]
except:
return False
return self.keycloak_admin.assign_realm_roles(user_id=user_id, roles=role)
# return self.keycloak_admin.assign_realm_roles(user_id=user_id, client_id=None, roles=role)
## CLIENTS
def delete_client(self, clientid):
self.connect()
return self.keycloak_admin.delete_client(clientid)
def add_client(self, client):
self.connect()
return self.keycloak_admin.create_client(client)

View File

@ -1,50 +0,0 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
#
# This file is part of DD
#
# DD is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with DD. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: AGPL-3.0-or-later
import json
import logging as log
import time
import traceback
from datetime import datetime, timedelta
import mysql.connector
import yaml
# from admin import app
class Mysql:
def __init__(self, host, database, user, password):
self.conn = mysql.connector.connect(
host=host, database=database, user=user, password=password
)
def select(self, sql):
self.cur = self.conn.cursor()
self.cur.execute(sql)
data = self.cur.fetchall()
self.cur.close()
return data
def update(self, sql):
self.cur = self.conn.cursor()
self.cur.execute(sql)
self.conn.commit()
self.cur.close()

View File

@ -1,71 +0,0 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
#
# This file is part of DD
#
# DD is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with DD. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: AGPL-3.0-or-later
import json
import logging as log
import time
import traceback
from datetime import datetime, timedelta
import psycopg2
import yaml
# from admin import app
class Postgres:
def __init__(self, host, database, user, password):
self.conn = psycopg2.connect(
host=host, database=database, user=user, password=password
)
# def __del__(self):
# self.cur.close()
# self.conn.close()
def select(self, sql):
self.cur = self.conn.cursor()
self.cur.execute(sql)
data = self.cur.fetchall()
self.cur.close()
return data
def update(self, sql):
self.cur = self.conn.cursor()
self.cur.execute(sql)
self.conn.commit()
self.cur.close()
# return self.cur.fetchall()
def select_with_headers(self, sql):
self.cur = self.conn.cursor()
self.cur.execute(sql)
data = self.cur.fetchall()
fields = [a.name for a in self.cur.description]
self.cur.close()
return (fields, data)
# def update_moodle_saml_plugin(self):
# plugin[('idpmetadata', '<md:EntitiesDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" Name="urn:keycloak"><md:EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="https://sso.'+app.config['DOMAIN']+'/auth/realms/master"><md:IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"><md:KeyDescriptor use="signing"><ds:KeyInfo><ds:KeyName>NrtA5ynG0htowP3SXw7dBJRIAMxn-1PwuuXwOwNhlRw</ds:KeyName><ds:X509Data><ds:X509Certificate>MIICmzCCAYMCBgF5jb0RCTANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjEwNTIxMDcwMjI4WhcNMzEwNTIxMDcwNDA4WjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCI8xh/C0+frz3kgWiUbziTDls71R2YiXLSVE+bw7gbEgZUGCLhoEI679azMtIxmnzM/snIX+yTb12+XoYkgbiLTMPQfnH+Kiab6g3HL3KPfhqS+yWkFxOoCp6Ibmp7yPlVWuHH+MBfO8OBr/r8Ao7heFbuzjiLd1KG67rcoaxfDgMuBoEomg1bgEjFgHaQIrSC6OZzH0h987/arqufZXeXlfyiqScMPUi+u5IpDWSwz06UKP0k8mxzNSlpZ93CKOUSsV0SMLxqg7FQ3SGiOk577bGW9o9BDTkkmSo3Up6smc0LzwvvUwuNd0B1irGkWZFQN9OXJnJYf1InEebIMtmPAgMBAAEwDQYJKoZIhvcNAQELBQADggEBADM34+qEGeBQ22luphVTuVJtGxcbxLx7DfsT0QfJD/OuxTTbNAa1VRyarb5juIAkqdj4y2quZna9ZXLecVo4RkwpzPoKoAkYA8b+kHnWqEwJi9iPrDvKb+GR0bBkLPN49YxIZ8IdKX/PRa3yuLHe+loiNsCaS/2ZK2KO46COsqU4QX1iVhF9kWphNLybjNAX45B6cJLsa1g0vXLdm3kv3SB4I2fErFVaOoDtFIjttoYlXdpUiThkPXBfr7N67P3dZHaS4tjJh+IZ8I6TINpcsH8dBkUhzYEIPHCePwSiC1w6WDBLNDuKt1mj1CZrLq+1x+Yhrs+QNRheEKGi89HZ8N0=</ds:X509Certificate></ds:X509Data></ds:KeyInfo></md:KeyDescriptor><md:ArtifactResolutionService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml/resolve" index="0"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat><md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/><md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://sso.mydomain.duckdns.org/auth/realms/master/protocol/saml"/></md:IDPSSODescriptor></md:EntityDescriptor></md:EntitiesDescriptor>')]
# pg_update = """UPDATE mdl_config_plugins set title = %s where plugin = auth_saml2 and name ="""
# cursor.execute(pg_update, (title, bookid))
# connection.commit()
# count = cursor.rowcount
# print(count, "Successfully Updated!")

View File

@ -30,8 +30,8 @@ from datetime import datetime, timedelta
import psycopg2 import psycopg2
import yaml import yaml
from lib.keycloak_client import KeycloakClient from admin.lib.keycloak_client import KeycloakClient
from lib.postgres import Postgres from admin.lib.postgres import Postgres
app = {} app = {}
app["config"] = {} app["config"] = {}
@ -93,7 +93,7 @@ class MoodleSaml:
time.sleep(2) time.sleep(2)
except: except:
log.error(traceback.format_exc()) log.error(traceback.format_exc())
log.info("Got moodle srt certificate.") log.info("Got moodle crt certificate.")
ready = False ready = False
while not ready: while not ready:

View File

@ -1,4 +1,5 @@
# #
# Copyright © 2022 Evilham
# Copyright © 2021,2022 IsardVDI S.L. # Copyright © 2021,2022 IsardVDI S.L.
# #
# This file is part of DD # This file is part of DD
@ -18,255 +19,57 @@
# #
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
import json
import logging as log
import os import os
import pprint from typing import Any, Dict, Optional
import random
import string
import time
import traceback
from datetime import datetime, timedelta
import psycopg2 from admin.lib.postgres import Postgres
import yaml
from lib.keycloak_client import KeycloakClient
from lib.postgres import Postgres
app = {} from saml_service import SamlService
app["config"] = {}
class NextcloudSaml(SamlService):
client_description : str = "Nextcloud Service Provider"
pg : Optional[Postgres]
class NextcloudSaml: def __init__(self) -> None:
def __init__(self): super(NextcloudSaml, self).__init__("nextcloud")
self.url = "http://dd-sso-keycloak:8080/auth/"
self.username = os.environ["KEYCLOAK_USER"]
self.password = os.environ["KEYCLOAK_PASSWORD"]
self.realm = "master"
self.verify = True
ready = False def connect_to_db(self) -> None:
while not ready: """
try: This is defined, though not currently used.
self.pg = Postgres( """
"dd-apps-postgresql", self.pg = Postgres(
"nextcloud", "dd-apps-postgresql",
os.environ["NEXTCLOUD_POSTGRES_USER"], "nextcloud",
os.environ["NEXTCLOUD_POSTGRES_PASSWORD"], os.environ["NEXTCLOUD_POSTGRES_USER"],
) os.environ["NEXTCLOUD_POSTGRES_PASSWORD"],
ready = True
except:
log.warning("Could not connect to nextcloud database. Retrying...")
time.sleep(2)
log.info("Connected to nextcloud database.")
basepath = os.path.dirname(__file__)
ready = False
while not ready:
try:
with open(
os.path.abspath(
os.path.join(basepath, "../saml_certs/public.cert")
),
"r",
) as crt:
app["config"]["PUBLIC_CERT"] = crt.read()
ready = True
except IOError:
log.warning(
"Could not get public certificate to be used in nextcloud. Retrying..."
)
log.warning(
" You should generate them: /admin/saml_certs # openssl req -nodes -new -x509 -keyout private.key -out public.cert"
)
time.sleep(2)
except:
log.error(traceback.format_exc())
log.info("Got moodle srt certificate to be used in nextcloud.")
ready = False
while not ready:
try:
with open(
os.path.abspath(
os.path.join(basepath, "../saml_certs/private.key")
),
"r",
) as pem:
app["config"]["PRIVATE_KEY"] = pem.read()
ready = True
except IOError:
log.warning(
"Could not get private key to be used in nextcloud. Retrying..."
)
log.warning(
" You should generate them: /admin/saml_certs # openssl req -nodes -new -x509 -keyout private.key -out public.cert"
)
time.sleep(2)
log.info("Got moodle pem certificate to be used in nextcloud.")
# ## This seems related to the fact that the certificate generated the first time does'nt work.
# ## And when regenerating the certificate de privatekeypass seems not to be used and instead it
# ## will use always this code as filename: 0f635d0e0f3874fff8b581c132e6c7a7
# ## As this bug I'm not able to solve, the process is:
# ## 1.- Bring up moodle and regenerate certificates on saml2 plugin in plugins-authentication
# ## 2.- Execute this script
# ## 3.- Cleanup all caches in moodle (Development tab)
# # with open(os.path.join("./moodledata/saml2/"+os.environ['NEXTCLOUD_SAML_PRIVATEKEYPASS'].replace("moodle."+os.environ['DOMAIN'],'')+'.idp.xml'),"w") as xml:
# # xml.write(self.parse_idp_metadata())
# with open(os.path.join("./moodledata/saml2/0f635d0e0f3874fff8b581c132e6c7a7.idp.xml"),"w") as xml:
# xml.write(self.parse_idp_metadata())
try:
self.reset_saml()
except:
print("Error resetting saml on nextcloud")
try:
self.delete_keycloak_nextcloud_saml_plugin()
except:
print("Error resetting saml on keycloak")
try:
self.set_nextcloud_saml_plugin()
except:
log.error(traceback.format_exc())
print("Error adding saml on nextcloud")
try:
self.add_keycloak_nextcloud_saml()
except:
print("Error adding saml on keycloak")
def connect(self):
self.keycloak = KeycloakClient(
url=self.url,
username=self.username,
password=self.password,
realm=self.realm,
verify=self.verify,
) )
# def activate_saml_plugin(self): def configure(self) -> None:
# ## After you need to purge moodle caches: /var/www/html # php admin/cli/purge_caches.php srv_base = f"https://nextcloud.{self.domain}"
# return self.pg.update("""UPDATE "mdl_config" SET value = 'email,saml2' WHERE "name" = 'auth'""") nc_saml_url= f"{srv_base}/apps/user_saml/saml"
nc_redir_url = f"{nc_saml_url}/acs"
nc_logout_url = f"{nc_saml_url}/sls"
client_id = f"{nc_saml_url}/metadata"
client_overrides : Dict[str, Any] = {
"name": self.client_name,
"description": self.client_description,
"clientId": client_id,
# def get_privatekey_pass(self):
# return self.pg.select("""SELECT * FROM "mdl_config" WHERE "name" = 'siteidentifier'""")[0][2]
def parse_idp_cert(self):
self.connect()
rsa = self.keycloak.get_server_rsa_key()
self.keycloak = None
return rsa["certificate"]
def set_keycloak_nextcloud_saml_plugin(self):
self.connect()
self.keycloak.add_nextcloud_client()
self.keycloak = None
def delete_keycloak_nextcloud_saml_plugin(self):
self.connect()
self.keycloak.delete_client("bef873f0-2079-4876-8657-067de27d01b7")
self.keycloak = None
def set_nextcloud_saml_plugin(self):
self.pg.update(
"""INSERT INTO "oc_appconfig" ("appid", "configkey", "configvalue") VALUES
('user_saml', 'general-uid_mapping', 'username'),
('user_saml', 'type', 'saml'),
('user_saml', 'sp-privateKey', '%s'),
('user_saml', 'saml-attribute-mapping-email_mapping', 'email'),
('user_saml', 'saml-attribute-mapping-quota_mapping', 'quota'),
('user_saml', 'saml-attribute-mapping-displayName_mapping', 'displayname'),
('user_saml', 'saml-attribute-mapping-group_mapping', 'member'),
('user_saml', 'idp-entityId', 'https://sso.%s/auth/realms/master'),
('user_saml', 'idp-singleSignOnService.url', 'https://sso.%s/auth/realms/master/protocol/saml'),
('user_saml', 'idp-x509cert', '%s'),
('user_saml', 'security-authnRequestsSigned', '1'),
('user_saml', 'security-logoutRequestSigned', '1'),
('user_saml', 'security-logoutResponseSigned', '1'),
('user_saml', 'security-wantMessagesSigned', '1'),
('user_saml', 'security-wantAssertionsSigned', '1'),
('user_saml', 'general-idp0_display_name', 'SAML Login'),
('user_saml', 'sp-x509cert', '%s'),
('user_saml', 'idp-singleLogoutService.url', 'https://sso.%s/auth/realms/master/protocol/saml');"""
% (
app["config"]["PRIVATE_KEY"],
os.environ["DOMAIN"],
os.environ["DOMAIN"],
self.parse_idp_cert(),
app["config"]["PUBLIC_CERT"],
os.environ["DOMAIN"],
)
)
def reset_saml(self):
cfg_list = [
"general-uid_mapping",
"sp-privateKey",
"saml-attribute-mapping-email_mapping",
"saml-attribute-mapping-quota_mapping",
"saml-attribute-mapping-displayName_mapping",
"saml-attribute-mapping-group_mapping",
"idp-entityId",
"idp-singleSignOnService.url",
"idp-x509cert",
"security-authnRequestsSigned",
"security-logoutRequestSigned",
"security-logoutResponseSigned",
"security-wantMessagesSigned",
"security-wantAssertionsSigned",
"general-idp0_display_name",
"type",
"sp-x509cert",
"idp-singleLogoutService.url",
]
for cfg in cfg_list:
self.pg.update(
"""DELETE FROM "oc_appconfig" WHERE appid = 'user_saml' AND configkey = '%s'"""
% (cfg)
)
def add_keycloak_nextcloud_saml(self):
client = {
"id": "bef873f0-2079-4876-8657-067de27d01b7",
"name": "nextcloud",
"description": "nextcloud",
"clientId": "https://nextcloud."
+ os.environ["DOMAIN"]
+ "/apps/user_saml/saml/metadata",
"surrogateAuthRequired": False,
"enabled": True, "enabled": True,
"alwaysDisplayInConsole": False,
"clientAuthenticatorType": "client-secret",
"redirectUris": [ "redirectUris": [
"https://nextcloud." + os.environ["DOMAIN"] + "/apps/user_saml/saml/acs" nc_redir_url
], ],
"webOrigins": ["https://nextcloud." + os.environ["DOMAIN"]],
"notBefore": 0,
"bearerOnly": False,
"consentRequired": False,
"standardFlowEnabled": True,
"implicitFlowEnabled": False,
"directAccessGrantsEnabled": False,
"serviceAccountsEnabled": False,
"publicClient": False,
"frontchannelLogout": True, "frontchannelLogout": True,
"protocol": "saml", "protocol": "saml",
"webOrigins": [srv_base],
"attributes": { "attributes": {
"saml.assertion.signature": True, "saml.assertion.signature": True,
"saml.force.post.binding": True, "saml.force.post.binding": True,
"saml_assertion_consumer_url_post": "https://nextcloud." "saml_assertion_consumer_url_post": nc_redir_url,
+ os.environ["DOMAIN"]
+ "/apps/user_saml/saml/acs",
"saml.server.signature": True, "saml.server.signature": True,
"saml.server.signature.keyinfo.ext": False, "saml.server.signature.keyinfo.ext": False,
"saml.signing.certificate": app["config"]["PUBLIC_CERT"], "saml.signing.certificate": self.public_cert,
"saml_single_logout_service_url_redirect": "https://nextcloud." "saml_single_logout_service_url_redirect": nc_logout_url,
+ os.environ["DOMAIN"]
+ "/apps/user_saml/saml/sls",
"saml.signature.algorithm": "RSA_SHA256", "saml.signature.algorithm": "RSA_SHA256",
"saml_force_name_id_format": False, "saml_force_name_id_format": False,
"saml.client.signature": False, "saml.client.signature": False,
@ -274,12 +77,8 @@ class NextcloudSaml:
"saml_name_id_format": "username", "saml_name_id_format": "username",
"saml_signature_canonicalization_method": "http://www.w3.org/2001/10/xml-exc-c14n#", "saml_signature_canonicalization_method": "http://www.w3.org/2001/10/xml-exc-c14n#",
}, },
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": True,
"nodeReRegistrationTimeout": -1,
"protocolMappers": [ "protocolMappers": [
{ {
"id": "e8e4acff-da2b-46aa-8bdb-ba42171671d6",
"name": "username", "name": "username",
"protocol": "saml", "protocol": "saml",
"protocolMapper": "saml-user-attribute-mapper", "protocolMapper": "saml-user-attribute-mapper",
@ -292,7 +91,6 @@ class NextcloudSaml:
}, },
}, },
{ {
"id": "8ab13cd7-822a-40d5-a1e1-9f556aed2332",
"name": "quota", "name": "quota",
"protocol": "saml", "protocol": "saml",
"protocolMapper": "saml-user-attribute-mapper", "protocolMapper": "saml-user-attribute-mapper",
@ -305,7 +103,6 @@ class NextcloudSaml:
}, },
}, },
{ {
"id": "28206b59-757b-4e3c-81cb-0b6053b1fd3d",
"name": "email", "name": "email",
"protocol": "saml", "protocol": "saml",
"protocolMapper": "saml-user-property-mapper", "protocolMapper": "saml-user-property-mapper",
@ -318,7 +115,6 @@ class NextcloudSaml:
}, },
}, },
{ {
"id": "5176a593-180f-4924-b294-b83a0d8d5972",
"name": "displayname", "name": "displayname",
"protocol": "saml", "protocol": "saml",
"protocolMapper": "saml-javascript-mapper", "protocolMapper": "saml-javascript-mapper",
@ -332,7 +128,6 @@ class NextcloudSaml:
}, },
}, },
{ {
"id": "e51e04b9-f71a-42de-819e-dd9285246ada",
"name": "Roles", "name": "Roles",
"protocol": "saml", "protocol": "saml",
"protocolMapper": "saml-role-list-mapper", "protocolMapper": "saml-role-list-mapper",
@ -345,7 +140,6 @@ class NextcloudSaml:
}, },
}, },
{ {
"id": "9c101249-bb09-4cc8-8f75-5a18fcb307e6",
"name": "group_list", "name": "group_list",
"protocol": "saml", "protocol": "saml",
"protocolMapper": "saml-group-membership-mapper", "protocolMapper": "saml-group-membership-mapper",
@ -359,24 +153,11 @@ class NextcloudSaml:
}, },
}, },
], ],
"defaultClientScopes": [
"web-origins",
"role_list",
"roles",
"profile",
"email",
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt",
],
"access": {"view": True, "configure": True, "manage": True},
} }
self.connect() self.set_client(client_id, client_overrides)
self.keycloak.add_client(client)
self.keycloak = None
n = NextcloudSaml() if __name__ == "__main__":
import logging as log
log.info("Configuring SAML client for Nextcloud")
NextcloudSaml().configure()

View File

@ -0,0 +1,119 @@
#
# Copyright © 2022 Evilham
#
# This file is part of DD
#
# DD is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with DD. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: AGPL-3.0-or-later
import logging as log
import os
import time
import traceback
from typing import Any, Dict
from admin.lib.keycloak_client import KeycloakClient
try:
# Python 3.9+
from functools import cache # type: ignore # Currently targetting 3.8
except ImportError:
# Up to python 3.8
from functools import cached_property as cache
class SamlService(object):
"""
Generic class to manage a SAML service on keycloak.
This is currently only used by Email and Nextcloud, but the plan is to
migrate other *_saml.py scripts to use this.
"""
keycloak: KeycloakClient
client_name: str
domain: str = os.environ["DOMAIN"]
def __init__(self, client_name : str):
self.keycloak = KeycloakClient()
self.client_name = client_name
@cache
def public_cert(self) -> str:
"""
Read the public SAML certificate as used by keycloak
"""
ready = False
basepath = os.path.dirname(__file__)
crt = ""
while not ready:
# TODO: Check why they were using a loop
try:
with open(
os.path.abspath(
os.path.join(basepath, f"../saml_certs/{self.client_name}.crt")
),
"r",
) as crtf:
crt = crtf.read()
ready = True
except IOError:
log.warning(f"Could not get public certificate for {self.client_name} SAML. Retrying...")
time.sleep(2)
except:
log.error(traceback.format_exc())
log.info("Got public SAML certificate")
return crt
def wait_for_database(self) -> None:
"""
Helper function to wait for a database to be up.
"""
ready = False
while not ready:
try:
self.connect_to_db()
ready = True
except:
log.warning("Could not connect to {self.client_name} database. Retrying...")
time.sleep(2)
log.info("Connected to {self.client_name} database.")
def connect_to_db(self) -> None:
"""
This should be overriden in subclasses.
"""
pass
def get_client(self, client_id: str) -> Any:
# TODO: merge with keycloak_config.py
self.keycloak.connect()
k = self.keycloak.keycloak_admin
clients = k.get_clients()
client = next(filter(lambda c: c["clientId"] == client_id, clients), None)
return (k, client)
def set_client(self, client_id: str, client_overrides: Dict[str, Any]) -> str:
(k, client) = self.get_client(client_id)
if client is None:
client_uid = k.create_client(client_overrides)
else:
client_uid = client["id"]
k.update_client(client_uid, client_overrides)
return client_id
def configure(self) -> None:
pass

View File

@ -30,8 +30,8 @@ from datetime import datetime, timedelta
import psycopg2 import psycopg2
import yaml import yaml
from lib.keycloak_client import KeycloakClient from admin.lib.keycloak_client import KeycloakClient
from lib.mysql import Mysql from admin.lib.mysql import Mysql
app = {} app = {}
app["config"] = {} app["config"] = {}
@ -72,7 +72,7 @@ class WordpressSaml:
try: try:
with open( with open(
os.path.abspath( os.path.abspath(
os.path.join(basepath, "../saml_certs/public.cert") os.path.join(basepath, "../saml_certs/wordpress.crt")
), ),
"r", "r",
) as crt: ) as crt:
@ -98,7 +98,7 @@ class WordpressSaml:
try: try:
with open( with open(
os.path.abspath( os.path.abspath(
os.path.join(basepath, "../saml_certs/private.key") os.path.join(basepath, "../saml_certs/wordpress.key")
), ),
"r", "r",
) as pem: ) as pem:

View File

@ -1,178 +0,0 @@
#
# Copyright © 2021,2022 IsardVDI S.L.
#
# This file is part of DD
#
# DD is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# DD is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with DD. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: AGPL-3.0-or-later
import json
import logging as log
import os
import pprint
import random
import string
import time
import traceback
from datetime import datetime, timedelta
import psycopg2
import yaml
from admin.lib.keycloak_client import KeycloakClient
from admin.lib.mysql import Mysql
app = {}
app["config"] = {}
class WordpressSaml:
def __init__(self):
self.url = "http://dd-sso-keycloak:8080/auth/"
self.username = os.environ["KEYCLOAK_USER"]
self.password = os.environ["KEYCLOAK_PASSWORD"]
self.realm = "master"
self.verify = True
ready = False
while not ready:
try:
self.db = Mysql(
"dd-apps-mariadb",
"wordpress",
os.environ["WORDPRESS_MARIADB_USER"],
os.environ["WORDPRESS_MARIADB_PASSWORD"],
)
ready = True
except:
log.warning("Could not connect to wordpress database. Retrying...")
time.sleep(2)
log.info("Connected to wordpress database.")
ready = False
while not ready:
try:
with open(os.path.join("./saml_certs/public.cert"), "r") as crt:
app["config"]["PUBLIC_CERT_RAW"] = crt.read()
app["config"]["PUBLIC_CERT"] = self.cert_prepare(
app["config"]["PUBLIC_CERT_RAW"]
)
ready = True
except IOError:
log.warning(
"Could not get public certificate to be used in wordpress. Retrying..."
)
log.warning(
" You should generate them: /admin/saml_certs # openssl req -nodes -new -x509 -keyout private.key -out public.cert"
)
time.sleep(2)
except:
log.error(traceback.format_exc())
log.info("Got moodle srt certificate to be used in wordpress.")
ready = False
while not ready:
try:
with open(os.path.join("./saml_certs/private.key"), "r") as pem:
app["config"]["PRIVATE_KEY"] = self.cert_prepare(pem.read())
ready = True
except IOError:
log.warning(
"Could not get private key to be used in wordpress. Retrying..."
)
log.warning(
" You should generate them: /admin/saml_certs # openssl req -nodes -new -x509 -keyout private.key -out public.cert"
)
time.sleep(2)
log.info("Got moodle pem certificate to be used in wordpress.")
# ## This seems related to the fact that the certificate generated the first time does'nt work.
# ## And when regenerating the certificate de privatekeypass seems not to be used and instead it
# ## will use always this code as filename: 0f635d0e0f3874fff8b581c132e6c7a7
# ## As this bug I'm not able to solve, the process is:
# ## 1.- Bring up moodle and regenerate certificates on saml2 plugin in plugins-authentication
# ## 2.- Execute this script
# ## 3.- Cleanup all caches in moodle (Development tab)
# # with open(os.path.join("./moodledata/saml2/"+os.environ['NEXTCLOUD_SAML_PRIVATEKEYPASS'].replace("moodle."+os.environ['DOMAIN'],'')+'.idp.xml'),"w") as xml:
# # xml.write(self.parse_idp_metadata())
# with open(os.path.join("./moodledata/saml2/0f635d0e0f3874fff8b581c132e6c7a7.idp.xml"),"w") as xml:
# xml.write(self.parse_idp_metadata())
try:
self.reset_saml_certs()
except:
print(traceback.format_exc())
print("Error resetting saml certs on wordpress")
try:
self.set_wordpress_saml_plugin_certs()
except:
print(traceback.format_exc())
print("Error adding saml on wordpress")
# SAML clients don't work well with composite roles so disabling and adding on realm
# self.add_client_roles()
# def activate_saml_plugin(self):
# ## After you need to purge moodle caches: /var/www/html # php admin/cli/purge_caches.php
# return self.db.update("""UPDATE "mdl_config" SET value = 'email,saml2' WHERE "name" = 'auth'""")
# def get_privatekey_pass(self):
# return self.db.select("""SELECT * FROM "mdl_config" WHERE "name" = 'siteidentifier'""")[0][2]
def connect(self):
self.keycloak = KeycloakClient(
url=self.url,
username=self.username,
password=self.password,
realm=self.realm,
verify=self.verify,
)
def cert_prepare(self, cert):
return "".join(cert.split("-----")[2].splitlines())
def parse_idp_cert(self):
self.connect()
rsa = self.keycloak.get_server_rsa_key()
self.keycloak = None
return rsa["certificate"]
def set_wordpress_saml_plugin_certs(self):
# ('active_plugins', 'a:2:{i:0;s:33:\"edwiser-bridge/edwiser-bridge.php\";i:1;s:17:\"onelogin_saml.php\";}', 'yes'),
self.db.update(
"""INSERT INTO wp_options (option_name, option_value, autoload) VALUES
('onelogin_saml_idp_x509cert', '%s', 'yes'),
('onelogin_saml_advanced_settings_sp_x509cert', '%s', 'yes'),
('onelogin_saml_advanced_settings_sp_privatekey', '%s', 'yes');"""
% (
self.parse_idp_cert(),
app["config"]["PUBLIC_CERT"],
app["config"]["PRIVATE_KEY"],
)
)
def reset_saml_certs(self):
self.db.update(
"""DELETE FROM wp_options WHERE option_name = 'onelogin_saml_idp_x509cert'"""
)
self.db.update(
"""DELETE FROM wp_options WHERE option_name = 'onelogin_saml_advanced_settings_sp_x509cert'"""
)
self.db.update(
"""DELETE FROM wp_options WHERE option_name = 'onelogin_saml_advanced_settings_sp_privatekey'"""
)
nw = WordpressSaml()

View File

@ -35,7 +35,7 @@ services:
- ${CUSTOM_PATH}/custom:/admin/custom:rw - ${CUSTOM_PATH}/custom:/admin/custom:rw
- ${DATA_FOLDER}/avatars:/admin/avatars:ro - ${DATA_FOLDER}/avatars:/admin/avatars:ro
- ${DATA_FOLDER}/moodle/saml2:/admin/moodledata/saml2:rw - ${DATA_FOLDER}/moodle/saml2:/admin/moodledata/saml2:rw
- ${DATA_FOLDER}/saml_certs:/admin/saml_certs:rw - ${DATA_FOLDER}/saml/public:/admin/saml_certs:ro
- ${DATA_FOLDER}/legal:/admin/admin/static/templates/pages/legal:rw - ${DATA_FOLDER}/legal:/admin/admin/static/templates/pages/legal:rw
- ${DATA_FOLDER}/dd-admin:/data:rw - ${DATA_FOLDER}/dd-admin:/data:rw
- ${DATA_FOLDER}/nc-mail-queue:/nc-mail-queue:rw - ${DATA_FOLDER}/nc-mail-queue:/nc-mail-queue:rw
@ -49,6 +49,7 @@ services:
- CUSTOM_FOLDER=/admin/custom - CUSTOM_FOLDER=/admin/custom
- NC_MAIL_QUEUE_FOLDER=/nc-mail-queue - NC_MAIL_QUEUE_FOLDER=/nc-mail-queue
- LEGAL_PATH=/admin/admin/static/templates/pages/legal - LEGAL_PATH=/admin/admin/static/templates/pages/legal
- PYTHONPATH=/admin
- AVATARS_SERVER_HOST=dd-sso-avatars:9000 - AVATARS_SERVER_HOST=dd-sso-avatars:9000
- AVATARS_ACCESS_KEY=${AVATARS_ACCESS_KEY:-AKIAIOSFODNN7EXAMPLE} - AVATARS_ACCESS_KEY=${AVATARS_ACCESS_KEY:-AKIAIOSFODNN7EXAMPLE}
- AVATARS_SECRET_KEY=${AVATARS_SECRET_KEY:-wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY} - AVATARS_SECRET_KEY=${AVATARS_SECRET_KEY:-wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY}