[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
parent
ede83e1514
commit
fdc3d74958
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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
122
dd-ctl
|
@ -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
|
||||||
|
|
|
@ -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}"
|
||||||
|
|
|
@ -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}"
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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"
|
|
|
@ -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()
|
|
|
@ -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!")
|
|
|
@ -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()
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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)
|
|
|
@ -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()
|
|
|
@ -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!")
|
|
|
@ -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:
|
||||||
|
|
|
@ -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(
|
self.pg = Postgres(
|
||||||
"dd-apps-postgresql",
|
"dd-apps-postgresql",
|
||||||
"nextcloud",
|
"nextcloud",
|
||||||
os.environ["NEXTCLOUD_POSTGRES_USER"],
|
os.environ["NEXTCLOUD_POSTGRES_USER"],
|
||||||
os.environ["NEXTCLOUD_POSTGRES_PASSWORD"],
|
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__)
|
def configure(self) -> None:
|
||||||
|
srv_base = f"https://nextcloud.{self.domain}"
|
||||||
|
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,
|
||||||
|
|
||||||
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):
|
|
||||||
# ## 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_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()
|
||||||
|
|
|
@ -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
|
|
@ -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:
|
||||||
|
|
|
@ -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()
|
|
|
@ -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}
|
||||||
|
|
Loading…
Reference in New Issue