Working with avatars extension

darta 2021-04-07 08:13:11 +00:00 committed by info
parent 91d8a620d6
commit 0323112f4f
1123 changed files with 668182 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.env
**/.env
main.conf
docker-compose.yml

20
CHANGELOG.md Normal file
View File

@ -0,0 +1,20 @@
# IsardVDI OpenID infrastructure
All notable changes to this project will be documented in this file.
## [alpha1] - not released
### Added
- Auth containers: freeipa, mokey, hydra
- Configuration file: main.conf
- Build docker-compose yml: build.sh
### Fixed
### Changed
### Removed

165
README.md
View File

@ -0,0 +1,165 @@
# IsardVDI - OpenID infrastructure
This will bring up a full OpenID auth infrastructure consisting in this hosts from $DOMAIN var set in main.conf:
- FreeIPA: https://ipa.$DOMAIN
- Mokey: https://login.$DOMAIN
- Hydra: https://hydra.$DOMAIN
NOTE: If you use the default example domain in main.conf.example you will need to add this domain mapping to IP in your hosts file at clients.
# Quick start
1. Edit main.conf (to suit your needs). If you set Letsencrypt vars it will ask and renew the certificate automatically.
2. Build docker.compose.yml: ```./build-compose.sh```
3. Bring containers up: ```docker-compose up -d```
Wait till freeipa container is ready (it can take SEVERAL minutes the first time): ```docker logs freeipa --follow```
And wait to be ready. It will log: *FreeIPA server configured.*
4. Add mokey client in freeipa: ```docker exec freeipa /bin/sh -c "scripts/mokey.sh"```
5. check that mokey is up: ```docker logs mokey --follow```. It will log: *⇨ http server started on [::]:8080*
Access your IP/DNS and login page should come up.
# Firewall
You should open 80 and 443
# Add client apps
## The easy way
[**Not working yet! Refer to 'Do it yourself'**]
For most client apps the default script will be enough:
```
docker exec \
-e APP_ID=<app name> \
-e APP_SECRET=<app secret> \
-e APP_CALLBACKS=<app callback url wiht https://...> \
hydra /bin/sh -c "scripts/add_app.sh"
```
## Do it yourself
For example we will be creating a moodle app client. You need to adapt the vars to your app
```
DOMAIN=(your domain root as set in main.conf)
APP_ID=moodle
APP_SECRET=Sup3rS3cr3t
```
```
docker-compose exec hydra \
hydra clients create \
--endpoint http://hydra:4445/ \
--id $APP_ID \
--secret $APP_SECRET \
--grant-types client_credentials,authorization_code,refresh_token \
--token-endpoint-auth-m01d4f1c0-8a53-4df5-8a46-de670f42a4dfethod client_secret_post \
--response-types code \
--scope openid,offline,profile,email \
--callbacks https://moodle.${DOMAIN}/auth/oidc/
```
And then validate the app:
```
docker-compose exec hydra \
hydra token client \
--endpoint http://hydra:4444/ \
--client-id $APP_ID \
--client-secret $APP_SECRET
```
## Example configuration for Moodle OpenID Connect
- authendpoint: https://hydra.${DOMAIN}/oauth2/auth
- tokenendpoint: https://hydra.${DOMAIN}/oauth2/token
- oidcresource: https://hydra.${DOMAIN}/userinfo
- oidcscope: openid profile email
- single_sign_off: [checked]
- logouturi: https://login.${DOMAIN}/auth/logout
## Example configuration for Nextcloud Custom OpenID Connect
- Prevent creating an account if the email address exists
- Update user profile every login
- Do not prune not available user groups on login
- automatically create groups if they do not exist
- Authorize url: https://hydra.${DOMAIN}/oauth2/auth
- Token url: https://hydra.${DOMAIN}/oauth2/token
- User info URL (optional): https://hydra.${DOMAIN}/userinfo
- Scope: openid profile offline email
- Logout URL (optional): https://login.${DOMAIN}/auth/logout
### Be aware on nextcloud behind proxy
Behind proxy we should force nextcloud-app to use https. From documentation it should be NEXTCLOUD_OVERWRITEPROTOCOL but it is not:
- https://docs.nextcloud.com/server/latest/admin_manual/configuration_server/reverse_proxy_configuration.html
Instead we used in docker/nextcloud/nextcloud.yml compose file the envvar NC_overwriteprotocol as stated in this thread:
- https://github.com/nextcl01d4f1c0-8a53-4df5-8a46-de670f42a4dfoud/docker/pull/819
Extra information about this:
Some users may get strange reply(Callback) url error from provider even if you pasted the right url, that's because your nextcloud
server may generate http urls when you are actually using https. Please set 'overwriteprotocol' => 'https', in your config.php file.
<?php
$CONFIG = array (
'overwriteprotocol' => 'https',
'memcache.local' => '\\OC\\Memcache\\APCu',
'apps_paths' =>
## Nexcloud autoredirect for unauthorized users
Set social_login_auto_redirect and social_login_http_client in config.php
- https://apps.nextcloud.com/apps/sociallogin
### APPENDIX: Nextcloud Social Login plugin
Config
You can use 'social_login_auto_redirect' => true setting in config.php for auto redirect unauthorized users to social login if only one
provider is configured. If you want to temporary disable this function (e.g. for login as local admin), you can add noredir=1 query
parameter in url for login page. Something like https://cloud.domain.com/login?noredir=1
To set timeout for http client, you can use
'social_login_http_client' => [
'timeout' => 45,
],
# IsardVDI office apps
Refer to https://gitlab.com/isard/isard-office repository for sample moodle, nextcloud, jitsi (and more) apps
# Troubleshooting
## FreeIPA
ldapsearch -x -b "dc=domain,dc=org" -H ldap://ipa.domain.org
ldapsearch -x -b "dc=domain,dc=org" -H ldap://ipa.domain.org -D "uid=admin,cn=users,cn=compat,dc=domain,dc=org" -W
# KEYCLOACK
Heres a list of OIDC endpoints that the Keycloak publishes. These URLs are useful if you are using a non-Keycloak client adapter to talk OIDC with the auth server. These are all relative URLs and the root of the URL being the HTTP(S) protocol, hostname, and usually path prefixed with /auth: i.e. https://localhost:8080/auth
/realms/{realm-name}/protocol/isard-sso-connect/token
This is the URL endpoint for obtaining a temporary code in the Authorization Code Flow or for obtaining tokens via the Implicit Flow, Direct Grants, or Client Grants.
/realms/{realm-name}/protocol/isard-sso-connect/auth
This is the URL endpoint for the Authorization Code Flow to turn a temporary code into a token.
/realms/{realm-name}/protocol/isard-sso-connect/logout
This is the URL endpoint for performing logouts.
/realms/{realm-name}/protocol/isard-sso-connect/userinfo
This is the URL endpoint for the User Info service described in the OIDC specification.
In all of these replace {realm-name} with the name of the realm.
http://login.santantoni.duckns.org/auth/realms/master/protocol/isard-sso-connect/logout

18
build-compose.sh Executable file
View File

@ -0,0 +1,18 @@
#!/bin/bash
set -e
#git submodule init
#git submodule update --recursive --remote
cp main.conf .env
## BUILD_ROOT_PATH env
# This is a workarround for
# https://github.com/docker/compose/issues/7873
# See also BUILD_ROOT_PATH sed section at the end of file
echo "BUILD_ROOT_PATH=$(pwd)" >> .env
. ./.env
cp .env docker-compose-parts/
docker-compose -f docker-compose-parts/haproxy.yml -f docker-compose-parts/freeipa.yml -f docker-compose-parts/keycloak.yml -f docker-compose-parts/avatars.yml -f docker-compose-parts/postgresql.yml -f docker-compose-parts/network.yml config > docker-compose.yml

View File

@ -0,0 +1,17 @@
version: '3.7'
services:
isard-sso-avatars:
image: minio/minio
container_name: isard-sso-avatars
volumes:
- ${DATA_FOLDER}/avatars:/data
- ${SRC_FOLDER}/avatars:/root/.minio
environment:
- MINIO_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE
- MINIO_SECRET_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
# depends_on:
# - ${KEYCLOAK_DB_ADDR}
command: "server /data"
networks:
- isard_net

View File

@ -0,0 +1,60 @@
---
version: '3.7'
services:
isard-sso-freeipa:
container_name: isard-sso-freeipa
image: freeipa/freeipa-server:centos-8
restart: unless-stopped
hostname: ipa.${DOMAIN}
environment:
- IPA_SERVER_HOSTNAME=ipa.${DOMAIN}
tty: true
stdin_open: true
cap_add:
- NET_ADMIN
volumes:
- ${BUILD_ROOT_PATH}/scripts/freeipa:/scripts
- /sys/fs/cgroup:/sys/fs/cgroup:ro
- ${DATA_FOLDER}/freeipa:/data
sysctls:
- net.ipv6.conf.lo.disable_ipv6=0
- net.ipv6.conf.all.disable_ipv6=0
security_opt:
- "seccomp:unconfined"
command:
- -U
- --domain=${DOMAIN}
- --realm=${DOMAIN}
- --http-pin=${IPA_ADMIN_PWD}
- --dirsrv-pin=${IPA_ADMIN_PWD}
- --ds-password=${IPA_ADMIN_PWD}
- --admin-password=${IPA_ADMIN_PWD}
- --no-host-dns
#- --no-dnssec-validation
#- --setup-dns
#- --auto-forwarders
#- --allow-zone-overlap
- --unattended
#ports:
#- "53:53/udp"
#- "53:53"
#- "80:80"
#- "443:443"
#- "389:389"
#- "636:636"
#- "88:88"
#- "464:464"
#- "88:88/udp"
#- "464:464/udp"
#- "123:123/udp"
#- "7389:7389"
#- "9443:9443"
#- "9444:9444"
#- "9445:9445"
env_file:
- .env
networks:
isard_net:
aliases:
- ${DOMAIN}
- ipa.${DOMAIN}

View File

@ -0,0 +1,22 @@
---
version: '3.7'
services:
isard-sso-haproxy:
build:
context: ${BUILD_ROOT_PATH}/docker/haproxy
dockerfile: Dockerfile
target: production
container_name: isard-sso-haproxy
restart: unless-stopped
volumes:
- ${SRC_FOLDER}/haproxy/letsencrypt:/etc/letsencrypt:rw
- ${SRC_FOLDER}/haproxy/certs:/certs:rw
networks:
- isard_net
ports:
- published: 80
target: 80
- published: 443
target: 443
env_file:
- .env

View File

@ -0,0 +1,33 @@
version: '3.7'
services:
isard-sso-keycloak:
image: quay.io/keycloak/keycloak:latest
container_name: isard-sso-keycloak
#hostname: sso.${DOMAIN}
volumes:
- ${BUILD_ROOT_PATH}/init/keycloak/jsons:/opt/jboss/keycloak/imports
- ${BUILD_ROOT_PATH}/init/keycloak/scripts/:/opt/jboss/startup-scripts/
- ${BUILD_ROOT_PATH}/docker/keycloak/themes:/opt/jboss/keycloak/themes/
- ${BUILD_ROOT_PATH}/docker/keycloak/extensions/keycloak-avatar-minio-extension/:/opt/custom/deployments
#- ${BUILD_ROOT_PATH}/docker/keycloak/extensions/keycloak-avatar-minio-extension/:/opt/custom/deployments
- ${BUILD_ROOT_PATH}/docker/keycloak/extensions/keycloak-avatar-minio-extension/avatar-minio-extension-bundle/target/avatar-minio-extension-bundle-1.0.1.0-SNAPSHOT.ear:/opt/jboss/keycloak/standalone/deployments/avatar-minio-extension-bundle-1.0.1.0-SNAPSHOT.ear
#- /opt/jboss/keycloak/standalone/configuration/keycloak-add-user.json
environment:
#- KEYCLOAK_IMPORT=/opt/jboss/keycloak/imports/realm-export.json
- DB_VENDOR=POSTGRES
- DB_ADDR=${KEYCLOAK_DB_ADDR}
- DB_DATABASE=${KEYCLOAK_DB_DATABASE}
- DB_USER=${KEYCLOAK_DB_USER}
- DB_SCHEMA=public
- DB_PASSWORD=${KEYCLOAK_DB_PASSWORD}
- KEYCLOAK_USER=${KEYCLOAK_USER}
- KEYCLOAK_PASSWORD=${KEYCLOAK_PASSWORD}
- PROXY_ADDRESS_FORWARDING=true
- KEYCLOAK_FRONTEND_URL=https://sso.${DOMAIN}/auth/
#- KEYCLOAK_LOGLEVEL=ALL
#- Dkeycloak.profile.feature.upload_scripts=enabled
depends_on:
- ${KEYCLOAK_DB_ADDR}
networks:
- isard_net

View File

@ -0,0 +1,41 @@
version: '3.6'
services:
keycloak:
build:
context: ${BUILD_ROOT_PATH}/docker/keycloak
dockerfile: Dockerfile
target: production
container_name: keycloak
hostname: sso.mydomain.com
volumes:
- ./imports:/opt/jboss/keycloak/imports
- /sys/fs/cgroup:/sys/fs/cgroup:ro
environment:
KEYCLOAK_IMPORT: /opt/jboss/keycloak/imports/realm-export.json -Dkeycloak.profile.feature.upload_scripts=enabled
DB_VENDOR: POSTGRES
DB_ADDR: postgres
DB_DATABASE: keycloak
DB_USER: keycloak
DB_SCHEMA: public
DB_PASSWORD: k3ycl0ak
KEYCLOAK_USER: admin
KEYCLOAK_PASSWORD: k3ycl0ak
#KEYCLOAK_LOGLEVEL: ALL
PROXY_ADDRESS_FORWARDING: "true"
KEYCLOAK_FRONTEND_URL: https://sso.mydomain.com/auth
# Uncomment the line below if you want to specify JDBC parameters. The parameter below is just an example, and it shouldn't be used in production without knowledge. It is highly recommended that you read the PostgreSQL JDBC driver documentation in order to use it.
#JDBC_PARAMS: "ssl=true"
#ports:
# - 8080:8080
#cap-add:
# - SYS_ADMIN
privileged: true
depends_on:
- postgres
networks:
- isard_net
networks:
isard_net:
name: isard_net

View File

@ -0,0 +1,5 @@
---
version: '3.7'
networks:
isard_net:
name: isard_net

View File

@ -0,0 +1,13 @@
version: "3.6"
services:
isard-sso-postgresql:
image: postgres:alpine
container_name: isard-sso-postgresql
restart: unless-stopped
env_file:
- .env
volumes:
- ${DB_FOLDER}/postgres:/var/lib/postgresql/data
- ${BUILD_ROOT_PATH}/init/databases:/docker-entrypoint-initdb.d
networks:
- isard_net

13
docker/haproxy/Dockerfile Normal file
View File

@ -0,0 +1,13 @@
FROM haproxy:alpine as production
RUN apk add openssl certbot py-pip
RUN pip install certbot-plugin-gandi
COPY letsencrypt-hook-deploy-concatenante.sh /
COPY letsencrypt.sh /usr/local/sbin/
COPY letsencrypt-renew-cron.sh /etc/periodic/daily/letsencrypt-renew
COPY auto-generate-certs.sh /usr/local/sbin/
COPY docker-entrypoint.sh /usr/local/bin/
RUN rm /docker-entrypoint.sh
RUN ln -s /usr/local/bin/docker-entrypoint.sh /
RUN chmod 775 docker-entrypoint.sh
ADD haproxy.conf /usr/local/etc/haproxy/haproxy.cfg

View File

@ -0,0 +1,31 @@
cd /certs
# Self signed cert generic data
C=CA
L=Barcelona
O=localdomain
CN_CA=$O
CN_HOST=*.$O
OU=$O
echo '#### Creating 2048-bit RSA key:'
openssl genrsa -out ca-key.pem 2048
echo '#### Using the key to create a self-signed certificate to your CA:'
openssl req -new -x509 -days 9999 -key ca-key.pem -out ca-cert.pem -sha256 \
-subj "/C=$C/L=$L/O=$O/CN=$CN_CA"
echo '#### Creating server certificate:'
openssl genrsa -out server-key.pem 2048
echo '#### Creating a certificate signing request for the server:'
openssl req -new -key server-key.pem -sha256 -out server-key.csr \
-subj "/CN=$CN_HOST"
echo '#### Creating server certificate:'
RND=$(( ( RANDOM % 1000 ) + 1 ))
openssl x509 -req -days 9999 -in server-key.csr -CA ca-cert.pem -CAkey ca-key.pem \
-set_serial $RND -sha256 -out server-cert.pem
echo '#### Concatenate certs for haprox'
cat server-cert.pem server-key.pem > chain.pem

View File

@ -0,0 +1,27 @@
#!/bin/sh
set -e
# Set debug path password
PASSWD=$(python3 -c 'import os,crypt,getpass; print(crypt.crypt(os.environ["IPA_ADMIN_PWD"], crypt.mksalt(crypt.METHOD_SHA512)))')
sed -i "/^ user admin password/c\ user admin password $PASSWD" /usr/local/etc/haproxy/haproxy.cfg
LETSENCRYPT_DOMAIN="$DOMAIN" letsencrypt.sh
if [ ! -e "/certs/chain.pem" ]; then
auto-generate-certs.sh
fi
# first arg is `-f` or `--some-option`
if [ "${1#-}" != "$1" ]; then
set -- haproxy "$@"
fi
if [ "$1" = 'haproxy' ]; then
shift # "haproxy"
# if the user wants "haproxy", let's add a couple useful flags
# -W -- "master-worker mode" (similar to the old "haproxy-systemd-wrapper"; allows for reload via "SIGUSR2")
# -db -- disables background mode
set -- haproxy -W -db "$@"
fi
exec "$@"

View File

@ -0,0 +1,23 @@
#!/bin/sh
set -e
prepare.sh
if [ ! -f /certs/chain.pem ]; then
auto-generate-certs.sh
fi
# first arg is `-f` or `--some-option`
if [ "${1#-}" != "$1" ]; then
set -- haproxy "$@"
fi
if [ "$1" = 'haproxy' ]; then
shift # "haproxy"
# if the user wants "haproxy", let's add a couple useful flags
# -W -- "master-worker mode" (similar to the old "haproxy-systemd-wrapper"; allows for reload via "SIGUSR2")
# -db -- disables background mode
set -- haproxy -W -db "$@"
fi
exec "$@"

145
docker/haproxy/haproxy.conf Normal file
View File

@ -0,0 +1,145 @@
resolvers mydns
nameserver dns1 127.0.0.11:53
global
# debug
daemon
log 127.0.0.1 local0
tune.ssl.default-dh-param 2048
defaults
mode http
timeout connect 25s
timeout client 25s
timeout client-fin 25s
timeout server 25s
timeout tunnel 7200s
option http-server-close
option httpclose
log global
option httplog
backlog 4096
maxconn 2000
option tcpka
frontend website
mode http
bind :80
redirect scheme https if !{ ssl_fc }
# http-request set-header SSL_CLIENT_CERT %[ssl_c_der,base64]
http-request del-header ssl_client_cert unless { ssl_fc_has_crt }
http-request set-header ssl_client_cert -----BEGIN\ CERTIFICATE-----\ %[ssl_c_der,base64]\ -----END\ CERTIFICATE-----\ if { ssl_fc_has_crt }
bind :443 ssl crt /certs/chain.pem
#cookie JSESSIONID prefix nocache
#use_backend be_hydra if { path_beg /hydra }
#use_backend be_hydra if { path_beg /oauth2 }
acl is_nextcloud hdr_beg(host) nextcloud.
acl is_moodle hdr_beg(host) moodle.
acl is_jitsi hdr_beg(host) jitsi.
acl is_oof hdr_beg(host) oof.
acl is_wp hdr_sub(host) .wp.
acl is_wp hdr_beg(host) wp.
acl is_pad hdr_beg(host) pad.
acl is_sso hdr_beg(host) sso.
acl is_ipa hdr_beg(host) ipa.
use_backend be_nextcloud if is_nextcloud
use_backend be_moodle if is_moodle
use_backend be_jitsi if is_jitsi
use_backend be_oof if is_oof
use_backend be_wp if is_wp
use_backend be_etherpad if is_pad
use_backend be_sso if is_sso
use_backend be_ipa if is_ipa
# default_backend be_sso
backend be_ipa
mode http
acl existing-x-forwarded-host req.hdr(X-Forwarded-Host) -m found
acl existing-x-forwarded-proto req.hdr(X-Forwarded-Proto) -m found
http-request add-header X-Forwarded-Host %[req.hdr(Host)] unless existing-x-forwarded-host
http-request add-header X-Forwarded-Proto https unless existing-x-forwarded-proto
server freeipa isard-sso-freeipa:443 check port 443 ssl verify none inter 5s rise 2 fall 10 resolvers mydns init-addr none
backend be_sso
mode http
option httpclose
#option http-server-close
option forwardfor
acl existing-x-forwarded-host req.hdr(X-Forwarded-Host) -m found
acl existing-x-forwarded-proto req.hdr(X-Forwarded-Proto) -m found
http-request add-header X-Forwarded-Host %[req.hdr(Host)] unless existing-x-forwarded-host
http-request add-header X-Forwarded-Proto https unless existing-x-forwarded-proto
server keycloak isard-sso-keycloak:8080 check port 8080 inter 5s rise 2 fall 10 resolvers mydns init-addr none
## APPS
backend be_moodle
mode http
acl existing-x-forwarded-host req.hdr(X-Forwarded-Host) -m found
acl existing-x-forwarded-proto req.hdr(X-Forwarded-Proto) -m found
http-request add-header X-Forwarded-Host %[req.hdr(Host)] unless existing-x-forwarded-host
http-request add-header X-Forwarded-Proto https unless existing-x-forwarded-proto
server moodle isard-apps-moodle:8080 check port 8080 inter 5s rise 2 fall 10 resolvers mydns init-addr none
backend be_nextcloud
mode http
acl existing-x-forwarded-host req.hdr(X-Forwarded-Host) -m found
acl existing-x-forwarded-proto req.hdr(X-Forwarded-Proto) -m found
http-request add-header X-Forwarded-Host %[req.hdr(Host)] unless existing-x-forwarded-host
http-request add-header X-Forwarded-Proto https unless existing-x-forwarded-proto
server nextcloud isard-apps-nextcloud-nginx:80 check port 80 inter 5s rise 2 fall 10 resolvers mydns init-addr none
backend be_etherpad
mode http
acl existing-x-forwarded-host req.hdr(X-Forwarded-Host) -m found
acl existing-x-forwarded-proto req.hdr(X-Forwarded-Proto) -m found
http-request add-header X-Forwarded-Host %[req.hdr(Host)] unless existing-x-forwarded-host
http-request add-header X-Forwarded-Proto https unless existing-x-forwarded-proto
server etherpad isard-apps-etherpad:9001 check port 9001 inter 5s rise 2 fall 10 resolvers mydns init-addr none
backend be_jitsi
mode http
acl existing-x-forwarded-host req.hdr(X-Forwarded-Host) -m found
acl existing-x-forwarded-proto req.hdr(X-Forwarded-Proto) -m found
http-request add-header X-Forwarded-Host %[req.hdr(Host)] unless existing-x-forwarded-host
http-request add-header X-Forwarded-Proto https unless existing-x-forwarded-proto
server jitsi isard-apps-jitsi:80 check port 80 inter 5s rise 2 fall 10 resolvers mydns init-addr none
backend be_oof
mode http
acl existing-x-forwarded-host req.hdr(X-Forwarded-Host) -m found
acl existing-x-forwarded-proto req.hdr(X-Forwarded-Proto) -m found
http-request add-header X-Forwarded-Host %[req.hdr(Host)] unless existing-x-forwarded-host
http-request add-header X-Forwarded-Proto https unless existing-x-forwarded-proto
server onlyoffice isard-apps-onlyoffice:80 check port 80 inter 5s rise 2 fall 10 resolvers mydns init-addr none
backend be_wp
mode http
acl existing-x-forwarded-host req.hdr(X-Forwarded-Host) -m found
acl existing-x-forwarded-proto req.hdr(X-Forwarded-Proto) -m found
http-request add-header X-Forwarded-Host %[req.hdr(Host)] unless existing-x-forwarded-host
http-request add-header X-Forwarded-Proto https unless existing-x-forwarded-proto
server wp isard-apps-wordpress:80 check port 80 inter 5s rise 2 fall 10 resolvers mydns init-addr none
listen stats
bind 0.0.0.0:8888
mode http
stats enable
option httplog
stats show-legends
stats uri /haproxy
stats realm Haproxy\ Statistics
stats refresh 5s
#stats auth staging:pep1n1ll0
#acl authorized http_auth(AuthUsers)
#stats http-request auth unless authorized
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms
userlist AuthUsers
user admin password $6$grgQMVfwI0XSGAQl$2usaQC9LVXXXYHtSkGUf74CIGsiH8fi/K.V6DuKSq0twPkmFGP2vL/b//Ulp2I4xBEZ3eYDhUbwBPK8jpmsbo.

View File

@ -0,0 +1,4 @@
#!/bin/sh
cat $RENEWED_LINEAGE/fullchain.pem $RENEWED_LINEAGE/privkey.pem > /certs/chain.pem
kill -SIGUSR2 1

View File

@ -0,0 +1,2 @@
#!/bin/sh
certbot renew --cert-name $LETSENCRYPT_DOMAIN

28
docker/haproxy/letsencrypt.sh Executable file
View File

@ -0,0 +1,28 @@
#!/bin/sh
if [ -f /letsencrypt-hook-deploy-concatenante.sh ]
then
mkdir -p /etc/letsencrypt/renewal-hooks/deploy/
mv /letsencrypt-hook-deploy-concatenante.sh /etc/letsencrypt/renewal-hooks/deploy/concatenate.sh
fi
if [ -n "$LETSENCRYPT_DOMAIN" -a -n "$LETSENCRYPT_EMAIL" ]
then
LETSENCRYPT_DOMAIN="$LETSENCRYPT_DOMAIN" crond
if [ -n "$GANDI_KEY" ]
then
touch /gandi.ini
chmod 600 /gandi.ini
echo "dns_gandi_api_key=$GANDI_KEY" > /gandi.ini
GANDI_OPTIONS="--authenticator dns-gandi --dns-gandi-credentials /gandi.ini"
else
GANDI_OPTIONS=""
fi
if [ ! -f /certs/chain.pem ]
then
if certbot certonly $GANDI_OPTIONS -d "$LETSENCRYPT_DOMAIN" -d "*.$LETSENCRYPT_DOMAIN" -m "$LETSENCRYPT_EMAIL" -n --agree-tos
then
RENEWED_LINEAGE="/etc/letsencrypt/live/$LETSENCRYPT_DOMAIN" /etc/letsencrypt/renewal-hooks/deploy/concatenate.sh
fi
fi
fi

View File

@ -0,0 +1,6 @@
FROM alpine:latest
RUN apk add --no-cache py3-pip
RUN pip3 install python-keycloak
ADD kcli.py /
COPY dumps /

View File

@ -0,0 +1,25 @@
import json, sys
from pprint import pprint
from keycloak import KeycloakAdmin
def keycloak_connect():
keycloak_admin = KeycloakAdmin(server_url="http://isard-sso-keycloak:8080/auth/",
username='admin',
password='keycloakkeycloak',
realm_name="master",
user_realm_name="only_if_other_realm_than_master",
client_secret_key="client-secret",
verify=True)
def keycloak_dumps():
print('Dumping keycloak config...')
def Keycloak_imports():
with open('saml_client.json') as json_file:
data = json.load(json_file)
if __name__ == "__main__":
keycloak_connect()
if sys.argv[1]=='dump':
keycloak_dumps()

View File

@ -0,0 +1,46 @@
{
"id" : "5254c35b-18b2-405d-ad88-80e870b14554",
"clientId" : "https://moodle.isardvdi.site/auth/saml2/sp/metadata.php",
"surrogateAuthRequired" : false,
"enabled" : true,
"alwaysDisplayInConsole" : false,
"clientAuthenticatorType" : "client-secret",
"redirectUris" : [ "https://moodle.isardvdi.site/auth/saml2/sp/saml2-acs.php/moodle.isardvdi.site" ],
"webOrigins" : [ "https://moodle.isardvdi.site" ],
"notBefore" : 0,
"bearerOnly" : false,
"consentRequired" : false,
"standardFlowEnabled" : true,
"implicitFlowEnabled" : false,
"directAccessGrantsEnabled" : false,
"serviceAccountsEnabled" : false,
"publicClient" : false,
"frontchannelLogout" : true,
"protocol" : "saml",
"attributes" : {
"saml.force.post.binding" : "true",
"saml.encrypt" : "true",
"saml_assertion_consumer_url_post" : "https://moodle.isardvdi.site/auth/saml2/sp/saml2-acs.php/moodle.isardvdi.site",
"saml.server.signature" : "true",
"saml.server.signature.keyinfo.ext" : "false",
"saml.signing.certificate" : "MIID4DCCAsigAwIBAgIBADANBgkqhkiG9w0BAQsFADCBiDEPMA0GA1UEAwwGbW9vZGxlMQswCQYDVQQGEwJBVTEfMB0GCSqGSIb3DQEJARYQdXNlckBleGFtcGxlLmNvbTEUMBIGA1UEBwwLbW9vZGxldmlsbGUxDzANBgNVBAoMBm1vb2RsZTEPMA0GA1UECAwGbW9vZGxlMQ8wDQYDVQQLDAZtb29kbGUwHhcNMjEwMzE1MTQwNTQ4WhcNMzEwMzEzMTQwNTQ4WjCBiDEPMA0GA1UEAwwGbW9vZGxlMQswCQYDVQQGEwJBVTEfMB0GCSqGSIb3DQEJARYQdXNlckBleGFtcGxlLmNvbTEUMBIGA1UEBwwLbW9vZGxldmlsbGUxDzANBgNVBAoMBm1vb2RsZTEPMA0GA1UECAwGbW9vZGxlMQ8wDQYDVQQLDAZtb29kbGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCV/f0IHhJhggl1HM4v2CYYkSaZvNeanNZtelGFWHKZnRUATRrVZVMNuEkyNCnHWifOvLdnkKy9kspUUOzhoTSVqq/9Piuru43kOuYL/kkrDyJm2RmMG1Oi2/Eldqx9PBpoUPW9/ORwSz7noRBLQcfkwxtt6Xa3Ye0be2fUfSy9IjMAU/to4tW60elZU1j0zllZHuk8ACCUA7M4BqFeVXZTrUDpu/rtzzboH8ti6/QwPbqsp9NsB1IFVFQM9QqtTFq3T2226ZGpgqeAJd8urx3ghJpGP9v6+O3g/LX99a/wqESYhU0yJ8Yyp4IL/P1aJjjB5bHqLT1pY/lTt397Zz/pAgMBAAGjUzBRMB0GA1UdDgQWBBTFQrz8CcnAmsA9fXJqewO2JcXMtjAfBgNVHSMEGDAWgBTFQrz8CcnAmsA9fXJqewO2JcXMtjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCKnn0TTvaVFfcb3NKUJQpCQQuPTLekXpVcQYK3alnfkNQ496yvtQ0gxet4gae4cGPCD8VkUgsSuIn7VeHwPVTeWHPcMH5nn4zI5+77jyZVaoDao8e7ZWo0qP5xhK/qvTARAWO3QGxfD480OT92wNX5IUuYb+XJ5yAAxXzMPJFt6tkkHfkLDaqnGgFYmtKo08fmbdX5232aAaN0gQOYdaybRt1Q3rOnlBmUoDatjertfutDfi312MS3S4e5mOaMOIaM2CupDnICRoXiv6/lBQu6N7c3ABzphzFlO4pXEqJhq2dOgfhIYD/HVhS77dGGj6n04UWN2OhN31mwtJ9D6aSk",
"saml_single_logout_service_url_redirect" : "https://moodle.isardvdi.site/auth/saml2/sp/saml2-logout.php/moodle.isardvdi.site",
"saml.signature.algorithm" : "RSA_SHA256",
"saml_force_name_id_format" : "false",
"saml.client.signature" : "true",
"saml.encryption.certificate" : "MIID4DCCAsigAwIBAgIBADANBgkqhkiG9w0BAQsFADCBiDEPMA0GA1UEAwwGbW9vZGxlMQswCQYDVQQGEwJBVTEfMB0GCSqGSIb3DQEJARYQdXNlckBleGFtcGxlLmNvbTEUMBIGA1UEBwwLbW9vZGxldmlsbGUxDzANBgNVBAoMBm1vb2RsZTEPMA0GA1UECAwGbW9vZGxlMQ8wDQYDVQQLDAZtb29kbGUwHhcNMjEwMzE1MTQwNTQ4WhcNMzEwMzEzMTQwNTQ4WjCBiDEPMA0GA1UEAwwGbW9vZGxlMQswCQYDVQQGEwJBVTEfMB0GCSqGSIb3DQEJARYQdXNlckBleGFtcGxlLmNvbTEUMBIGA1UEBwwLbW9vZGxldmlsbGUxDzANBgNVBAoMBm1vb2RsZTEPMA0GA1UECAwGbW9vZGxlMQ8wDQYDVQQLDAZtb29kbGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCV/f0IHhJhggl1HM4v2CYYkSaZvNeanNZtelGFWHKZnRUATRrVZVMNuEkyNCnHWifOvLdnkKy9kspUUOzhoTSVqq/9Piuru43kOuYL/kkrDyJm2RmMG1Oi2/Eldqx9PBpoUPW9/ORwSz7noRBLQcfkwxtt6Xa3Ye0be2fUfSy9IjMAU/to4tW60elZU1j0zllZHuk8ACCUA7M4BqFeVXZTrUDpu/rtzzboH8ti6/QwPbqsp9NsB1IFVFQM9QqtTFq3T2226ZGpgqeAJd8urx3ghJpGP9v6+O3g/LX99a/wqESYhU0yJ8Yyp4IL/P1aJjjB5bHqLT1pY/lTt397Zz/pAgMBAAGjUzBRMB0GA1UdDgQWBBTFQrz8CcnAmsA9fXJqewO2JcXMtjAfBgNVHSMEGDAWgBTFQrz8CcnAmsA9fXJqewO2JcXMtjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCKnn0TTvaVFfcb3NKUJQpCQQuPTLekXpVcQYK3alnfkNQ496yvtQ0gxet4gae4cGPCD8VkUgsSuIn7VeHwPVTeWHPcMH5nn4zI5+77jyZVaoDao8e7ZWo0qP5xhK/qvTARAWO3QGxfD480OT92wNX5IUuYb+XJ5yAAxXzMPJFt6tkkHfkLDaqnGgFYmtKo08fmbdX5232aAaN0gQOYdaybRt1Q3rOnlBmUoDatjertfutDfi312MS3S4e5mOaMOIaM2CupDnICRoXiv6/lBQu6N7c3ABzphzFlO4pXEqJhq2dOgfhIYD/HVhS77dGGj6n04UWN2OhN31mwtJ9D6aSk",
"saml.authnstatement" : "true",
"saml_name_id_format" : "username",
"saml_signature_canonicalization_method" : "http://www.w3.org/2001/10/xml-exc-c14n#"
},
"authenticationFlowBindingOverrides" : { },
"fullScopeAllowed" : true,
"nodeReRegistrationTimeout" : -1,
"defaultClientScopes" : [ "web-origins", "role_list", "roles", "profile", "email" ],
"optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ],
"access" : {
"view" : true,
"configure" : true,
"manage" : true
}
},

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,95 @@
{
"id" : "0457e842-172b-42bf-8569-814625e7b019",
"clientId" : "https://moodle.santantoni.duckdns.org/auth/saml2/sp/metadata.php",
"surrogateAuthRequired" : false,
"enabled" : true,
"alwaysDisplayInConsole" : false,
"clientAuthenticatorType" : "client-secret",
"redirectUris" : [ "https://moodle.santantoni.duckdns.org/auth/saml2/sp/saml2-acs.php/moodle.santantoni.duckdns.org" ],
"webOrigins" : [ "https://moodle.santantoni.duckdns.org" ],
"notBefore" : 0,
"bearerOnly" : false,
"consentRequired" : false,
"standardFlowEnabled" : true,
"implicitFlowEnabled" : false,
"directAccessGrantsEnabled" : false,
"serviceAccountsEnabled" : false,
"publicClient" : false,
"frontchannelLogout" : true,
"protocol" : "saml",
"attributes" : {
"saml.force.post.binding" : "true",
"saml.encrypt" : "true",
"saml_assertion_consumer_url_post" : "https://moodle.santantoni.duckdns.org/auth/saml2/sp/saml2-acs.php/moodle.santantoni.duckdns.org",
"saml.server.signature" : "true",
"saml.server.signature.keyinfo.ext" : "false",
"saml.signing.certificate" : "MIIECjCCAvKgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBnTEPMA0GA1UEAwwGbW9vZGxlMQswCQYDVQQGEwJBVTEUMBIGA1UEBwwLbW9vZGxldmlsbGUxNDAyBgkqhkiG9w0BCQEWJW5vcmVwbHlAbW9vZGxlLnNhbnRhbnRvbmkuZHVja2Rucy5vcmcxDzANBgNVBAoMBm1vb2RsZTEPMA0GA1UECAwGbW9vZGxlMQ8wDQYDVQQLDAZtb29kbGUwHhcNMjEwNDE4MDkxODE5WhcNMzEwNDE2MDkxODE5WjCBnTEPMA0GA1UEAwwGbW9vZGxlMQswCQYDVQQGEwJBVTEUMBIGA1UEBwwLbW9vZGxldmlsbGUxNDAyBgkqhkiG9w0BCQEWJW5vcmVwbHlAbW9vZGxlLnNhbnRhbnRvbmkuZHVja2Rucy5vcmcxDzANBgNVBAoMBm1vb2RsZTEPMA0GA1UECAwGbW9vZGxlMQ8wDQYDVQQLDAZtb29kbGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDHwSt88KWeyVVOIn452hwLgWScvClh0B+U1k5SaLsmvodbGFwhYpedAWjVsdJvCIopmB0XFmffLl5OJMGA8arUxmiVMKlaX3gNi3B6GIpcEADxA+KHJz5LnOPH6zIFUNRLtXIdQo+QTpCkxkeRh3VGE3kiEKtMlPhNCJR3EADTgd5NgDJ8dcyPmk76GoynnBDObICOQtbydlsBVAlxa/WwVKa6SLTsGrMZnDBDF4PATBvzqOJTGEqc7S5lNs95je7vlkEukSj2xGfo6Dk4OYa0dgV5vR7fMwH71JppuwG0pqY5//YFaQ5hz9eCUJwDnaCWi0KPxMAxCh0XakXrTvAFAgMBAAGjUzBRMB0GA1UdDgQWBBRUksQbGQ74ntYNzRflVwMjN85Q+jAfBgNVHSMEGDAWgBRUksQbGQ74ntYNzRflVwMjN85Q+jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBkJJ3QGTi0hAM34XSnu9C+JbFgwpY6T2FkhSOgRY3NwNnR/IP03uxL1IwxwarN7KnkUwv2SYztpIa/MSnPeQ/c8ZlSh7EOLog1fNu1Rn4H4O5gw2GNixsz3TtR8dQkmXHdiGFmGL3XukLHj3PXpJciLoh0+i3xPyV2xNcYURxEnMx3fQ6f+0Me1JBMt3BgWbv9UZCKX6VDnpSTH/qcgRHKdenHnbjZXP22A8HkLDpQO7e5sBv+RNs8UwQiNI3NxZyjLLyNJdzAp0Z2UfC/jYRooQiehOdkqx2BS00l1dQfcQGvEBfgRWWkMdn8wE62blTofcQqfz3K30DEijLeHDks",
"saml_single_logout_service_url_redirect" : "https://moodle.santantoni.duckdns.org/auth/saml2/sp/saml2-logout.php/moodle.santantoni.duckdns.org",
"saml.signature.algorithm" : "RSA_SHA256",
"saml_force_name_id_format" : "false",
"saml.client.signature" : "true",
"saml.encryption.certificate" : "MIIECjCCAvKgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBnTEPMA0GA1UEAwwGbW9vZGxlMQswCQYDVQQGEwJBVTEUMBIGA1UEBwwLbW9vZGxldmlsbGUxNDAyBgkqhkiG9w0BCQEWJW5vcmVwbHlAbW9vZGxlLnNhbnRhbnRvbmkuZHVja2Rucy5vcmcxDzANBgNVBAoMBm1vb2RsZTEPMA0GA1UECAwGbW9vZGxlMQ8wDQYDVQQLDAZtb29kbGUwHhcNMjEwNDE4MDkxODE5WhcNMzEwNDE2MDkxODE5WjCBnTEPMA0GA1UEAwwGbW9vZGxlMQswCQYDVQQGEwJBVTEUMBIGA1UEBwwLbW9vZGxldmlsbGUxNDAyBgkqhkiG9w0BCQEWJW5vcmVwbHlAbW9vZGxlLnNhbnRhbnRvbmkuZHVja2Rucy5vcmcxDzANBgNVBAoMBm1vb2RsZTEPMA0GA1UECAwGbW9vZGxlMQ8wDQYDVQQLDAZtb29kbGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDHwSt88KWeyVVOIn452hwLgWScvClh0B+U1k5SaLsmvodbGFwhYpedAWjVsdJvCIopmB0XFmffLl5OJMGA8arUxmiVMKlaX3gNi3B6GIpcEADxA+KHJz5LnOPH6zIFUNRLtXIdQo+QTpCkxkeRh3VGE3kiEKtMlPhNCJR3EADTgd5NgDJ8dcyPmk76GoynnBDObICOQtbydlsBVAlxa/WwVKa6SLTsGrMZnDBDF4PATBvzqOJTGEqc7S5lNs95je7vlkEukSj2xGfo6Dk4OYa0dgV5vR7fMwH71JppuwG0pqY5//YFaQ5hz9eCUJwDnaCWi0KPxMAxCh0XakXrTvAFAgMBAAGjUzBRMB0GA1UdDgQWBBRUksQbGQ74ntYNzRflVwMjN85Q+jAfBgNVHSMEGDAWgBRUksQbGQ74ntYNzRflVwMjN85Q+jAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBkJJ3QGTi0hAM34XSnu9C+JbFgwpY6T2FkhSOgRY3NwNnR/IP03uxL1IwxwarN7KnkUwv2SYztpIa/MSnPeQ/c8ZlSh7EOLog1fNu1Rn4H4O5gw2GNixsz3TtR8dQkmXHdiGFmGL3XukLHj3PXpJciLoh0+i3xPyV2xNcYURxEnMx3fQ6f+0Me1JBMt3BgWbv9UZCKX6VDnpSTH/qcgRHKdenHnbjZXP22A8HkLDpQO7e5sBv+RNs8UwQiNI3NxZyjLLyNJdzAp0Z2UfC/jYRooQiehOdkqx2BS00l1dQfcQGvEBfgRWWkMdn8wE62blTofcQqfz3K30DEijLeHDks",
"saml.authnstatement" : "true",
"saml_name_id_format" : "username",
"saml_signature_canonicalization_method" : "http://www.w3.org/2001/10/xml-exc-c14n#"
},
"authenticationFlowBindingOverrides" : { },
"fullScopeAllowed" : true,
"nodeReRegistrationTimeout" : -1,
"protocolMappers" : [ {
"id" : "6e05e32c-436a-4b35-a376-801058b757ac",
"name" : "X500 email",
"protocol" : "saml",
"protocolMapper" : "saml-user-property-mapper",
"consentRequired" : false,
"config" : {
"attribute.nameformat" : "urn:oasis:names:tc:SAML:2.0:attrname-format:uri",
"user.attribute" : "email",
"friendly.name" : "email",
"attribute.name" : "urn:oid:1.2.840.113549.1.9.1"
}
}, {
"id" : "0d5e1994-1de1-49cd-bad5-f48c31926019",
"name" : "X500 givenName",
"protocol" : "saml",
"protocolMapper" : "saml-user-property-mapper",
"consentRequired" : false,
"config" : {
"attribute.nameformat" : "urn:oasis:names:tc:SAML:2.0:attrname-format:uri",
"user.attribute" : "firstName",
"friendly.name" : "givenName",
"attribute.name" : "urn:oid:2.5.4.42"
}
}, {
"id" : "8807929f-20da-4185-b37a-fa414bbe7a35",
"name" : "username",
"protocol" : "saml",
"protocolMapper" : "saml-user-property-mapper",
"consentRequired" : false,
"config" : {
"attribute.nameformat" : "Basic",
"user.attribute" : "username",
"friendly.name" : "username",
"attribute.name" : "username"
}
}, {
"id" : "04ad9cbb-f7cf-4759-8993-9b9ce5075ebc",
"name" : "X500 surname",
"protocol" : "saml",
"protocolMapper" : "saml-user-property-mapper",
"consentRequired" : false,
"config" : {
"attribute.nameformat" : "urn:oasis:names:tc:SAML:2.0:attrname-format:uri",
"user.attribute" : "lastName",
"friendly.name" : "surname",
"attribute.name" : "urn:oid:2.5.4.4"
}
} ],
"defaultClientScopes" : [ "web-origins", "role_list", "profile", "roles", "email" ],
"optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ],
"access" : {
"view" : true,
"configure" : true,
"manage" : true
}
}

View File

@ -0,0 +1,41 @@
FROM centos:centos8 as production
# https://mtembo.com/technology/installing-keycloak/
#RUN dnf update -y
RUN dnf install -y java-1.8.0-openjdk-devel wget curl zip
RUN mkdir -p /opt/keycloak
RUN wget https://github.com/keycloak/keycloak/releases/download/12.0.4/keycloak-12.0.4.zip -P /opt/keycloak
WORKDIR /opt/keycloak
RUN unzip keycloak-12.0.4.zip -d /opt/keycloak
WORKDIR /opt/keycloak/keycloak-12.0.4
### Proxy environment. This should be done in keycloak.cli but don't know how
RUN sed -i 's/<http-listener name="default" socket-binding="http" redirect-socket="https" enable-http2="true"\/>/<http-listener name="default" socket-binding="http" redirect-socket="https" enable-http2="true" proxy-address-forwarding="true"\/>/g' standalone/configuration/standalone.xml
# postgres
# Download PostgreSQL database drivers
#RUN mkdir -p /opt/drivers/jdbc
#RUN wget https://jdbc.postgresql.org/download/postgresql-42.2.16.jar -P /opt/drivers/jdbc
#RUN /opt/keycloak/keycloak-12.0.4/bin/jboss-cli.sh -c --commands="module add --name=org.postgresql --dependencies=javax.api,javax.transaction.api --resources=/opt/drivers/jdbc/postgresql-42.2.16.jar"
#RUN /subsystem=datasources/jdbc-driver=postgresql:add(driver-name=postgresql,driver-module-name=org.postgresql,driver-class-name=org.postgresql.Driver)
#RUN /subsystem=datasources/data-source=KeycloakDS:remove
#RUN /subsystem=datasources/data-source=KeycloakDS:add(driver-name=postgresql,enabled=true,use-java-context=true,connection-url="jdbc:postgresql://host:port/database",jndi-name="java:/jboss/datasources/KeycloakDS",user-name=keycloak,password="PASSWORD",max-pool-size=20)
#RUN standalone.sh -b 0.0.0.0 -Djboss.http.port=9080 -Dkeycloak.import=/keycloak-work/freeipa-realm.json
RUN dnf -y install systemd; dnf clean all; \
(cd /lib/systemd/system/sysinit.target.wants/; for i in *; do [ $i == systemd-tmpfiles-setup.service ] || rm -f $i; done); \
rm -f /lib/systemd/system/multi-user.target.wants/*;\
rm -f /etc/systemd/system/*.wants/*;\
rm -f /lib/systemd/system/local-fs.target.wants/*; \
rm -f /lib/systemd/system/sockets.target.wants/*udev*; \
rm -f /lib/systemd/system/sockets.target.wants/*initctl*; \
rm -f /lib/systemd/system/basic.target.wants/*;\
rm -f /lib/systemd/system/anaconda.target.wants/*;
RUN dnf -y install freeipa-client freeipa-admintools jna sssd-dbus
RUN wget https://download.copr.fedorainfracloud.org/results/giesen/libunix-dbus-java/epel-8-x86_64/01134406-libunix-dbus-java/libunix-dbus-java-0.8.0-2.el8.x86_64.rpm
RUN dnf install -y libunix-dbus-java-0.8.0-2.el8.x86_64.rpm
ADD docker-entrypoint.sh /
RUN chmod 700 /docker-entrypoint.sh
CMD ["/docker-entrypoint.sh"]
#CMD ["/opt/keycloak/keycloak-12.0.4/bin/standalone.sh","-b","0.0.0.0"]

View File

@ -0,0 +1,41 @@
FROM centos/systemd as production
# https://mtembo.com/technology/installing-keycloak/
#RUN yum update -y
RUN yum install -y java-1.8.0-openjdk-devel wget curl zip unzip
RUN mkdir -p /opt/keycloak
RUN wget https://github.com/keycloak/keycloak/releases/download/12.0.4/keycloak-12.0.4.zip -P /opt/keycloak
WORKDIR /opt/keycloak
RUN unzip keycloak-12.0.4.zip -d /opt/keycloak
WORKDIR /opt/keycloak/keycloak-12.0.4
### Proxy environment. This should be done in keycloak.cli but don't know how
RUN sed -i 's/<http-listener name="default" socket-binding="http" redirect-socket="https" enable-http2="true"\/>/<http-listener name="default" socket-binding="http" redirect-socket="https" enable-http2="true" proxy-address-forwarding="true"\/>/g' standalone/configuration/standalone.xml
# postgres
# Download PostgreSQL database drivers
#RUN mkdir -p /opt/drivers/jdbc
#RUN wget https://jdbc.postgresql.org/download/postgresql-42.2.16.jar -P /opt/drivers/jdbc
#RUN /opt/keycloak/keycloak-12.0.4/bin/jboss-cli.sh -c --commands="module add --name=org.postgresql --dependencies=javax.api,javax.transaction.api --resources=/opt/drivers/jdbc/postgresql-42.2.16.jar"
#RUN /subsystem=datasources/jdbc-driver=postgresql:add(driver-name=postgresql,driver-module-name=org.postgresql,driver-class-name=org.postgresql.Driver)
#RUN /subsystem=datasources/data-source=KeycloakDS:remove
#RUN /subsystem=datasources/data-source=KeycloakDS:add(driver-name=postgresql,enabled=true,use-java-context=true,connection-url="jdbc:postgresql://host:port/database",jndi-name="java:/jboss/datasources/KeycloakDS",user-name=keycloak,password="PASSWORD",max-pool-size=20)
#RUN standalone.sh -b 0.0.0.0 -Djboss.http.port=9080 -Dkeycloak.import=/keycloak-work/freeipa-realm.json
RUN yum -y install systemd; yum clean all; \
(cd /lib/systemd/system/sysinit.target.wants/; for i in *; do [ $i == systemd-tmpfiles-setup.service ] || rm -f $i; done); \
rm -f /lib/systemd/system/multi-user.target.wants/*;\
rm -f /etc/systemd/system/*.wants/*;\
rm -f /lib/systemd/system/local-fs.target.wants/*; \
rm -f /lib/systemd/system/sockets.target.wants/*udev*; \
rm -f /lib/systemd/system/sockets.target.wants/*initctl*; \
rm -f /lib/systemd/system/basic.target.wants/*;\
rm -f /lib/systemd/system/anaconda.target.wants/*;
RUN yum -y install freeipa-client freeipa-admintools jna sssd-dbus
RUN wget https://download.copr.fedorainfracloud.org/results/giesen/libunix-dbus-java/epel-8-x86_64/01134406-libunix-dbus-java/libunix-dbus-java-0.8.0-2.el8.x86_64.rpm
RUN yum install -y libunix-dbus-java-0.8.0-2.el8.x86_64.rpm
ADD docker-entrypoint.sh /
RUN chmod 700 /docker-entrypoint.sh
CMD ["/docker-entrypoint.sh"]
#CMD ["/opt/keycloak/keycloak-12.0.4/bin/standalone.sh","-b","0.0.0.0"]

View File

@ -0,0 +1,43 @@
FROM centos:centos8 as production
# https://gist.githubusercontent.com/mottyc/bcea44c569d6374d125abbb44cf97bb2/raw/067399f6ea4506eeaa08e04c0786fa3d86564697/keycloack-install
# https://markandruth.co.uk/2020/10/10/running-systemd-inside-a-centos-8-docker-container
# https://mtembo.com/technology/installing-keycloak/
#RUN dnf update -y
RUN dnf install -y java-1.8.0-openjdk-devel wget curl zip
RUN mkdir -p /opt/keycloak
RUN wget https://github.com/keycloak/keycloak/releases/download/12.0.4/keycloak-12.0.4.zip -P /opt/keycloak
WORKDIR /opt/keycloak
RUN unzip keycloak-12.0.4.zip -d /opt/keycloak
WORKDIR /opt/keycloak/keycloak-12.0.4
### Proxy environment. This should be done in keycloak.cli but don't know how
RUN sed -i 's/<http-listener name="default" socket-binding="http" redirect-socket="https" enable-http2="true"\/>/<http-listener name="default" socket-binding="http" redirect-socket="https" enable-http2="true" proxy-address-forwarding="true"\/>/g' standalone/configuration/standalone.xml
# postgres
# Download PostgreSQL database drivers
#RUN mkdir -p /opt/drivers/jdbc
#RUN wget https://jdbc.postgresql.org/download/postgresql-42.2.16.jar -P /opt/drivers/jdbc
#RUN /opt/keycloak/keycloak-12.0.4/bin/jboss-cli.sh -c --commands="module add --name=org.postgresql --dependencies=javax.api,javax.transaction.api --resources=/opt/drivers/jdbc/postgresql-42.2.16.jar"
#RUN /subsystem=datasources/jdbc-driver=postgresql:add(driver-name=postgresql,driver-module-name=org.postgresql,driver-class-name=org.postgresql.Driver)
#RUN /subsystem=datasources/data-source=KeycloakDS:remove
#RUN /subsystem=datasources/data-source=KeycloakDS:add(driver-name=postgresql,enabled=true,use-java-context=true,connection-url="jdbc:postgresql://host:port/database",jndi-name="java:/jboss/datasources/KeycloakDS",user-name=keycloak,password="PASSWORD",max-pool-size=20)
#RUN standalone.sh -b 0.0.0.0 -Djboss.http.port=9080 -Dkeycloak.import=/keycloak-work/freeipa-realm.json
RUN dnf -y install systemd; dnf clean all; \
(cd /lib/systemd/system/sysinit.target.wants/; for i in *; do [ $i == systemd-tmpfiles-setup.service ] || rm -f $i; done); \
rm -f /lib/systemd/system/multi-user.target.wants/*;\
rm -f /etc/systemd/system/*.wants/*;\
rm -f /lib/systemd/system/local-fs.target.wants/*; \
rm -f /lib/systemd/system/sockets.target.wants/*udev*; \
rm -f /lib/systemd/system/sockets.target.wants/*initctl*; \
rm -f /lib/systemd/system/basic.target.wants/*;\
rm -f /lib/systemd/system/anaconda.target.wants/*;
RUN dnf -y install freeipa-client freeipa-admintools jna sssd-dbus
RUN wget https://download.copr.fedorainfracloud.org/results/giesen/libunix-dbus-java/epel-8-x86_64/01134406-libunix-dbus-java/libunix-dbus-java-0.8.0-2.el8.x86_64.rpm
RUN dnf install -y libunix-dbus-java-0.8.0-2.el8.x86_64.rpm
ADD docker-entrypoint.sh /
RUN chmod 700 /docker-entrypoint.sh
CMD ["/docker-entrypoint.sh"]
#CMD ["/opt/keycloak/keycloak-12.0.4/bin/standalone.sh","-b","0.0.0.0"]

View File

@ -0,0 +1,2 @@
Centos7: Does not register to freeipa. It has systemd
Centos8: Missing dbus, sssd and asks for systemd

View File

@ -0,0 +1,26 @@
#!/bin/sh
#set -e
# https://spoore.wordpress.com/2017/02/21/how-to-setup-keycloi/
ipa-client-install \
--fixed-primary \
--server ipa.$DOMAIN \
--domain $DOMAIN \
--principal admin \
--password freeipafreeipa \
--unattended \
--no-nisdomain \
--force-join \
-N # No NTP now
echo freeipafreeipa|kinit admin
ipa service-add HTTP/sso.santantoni.duckdns.org@SANTANTONI.DUCKDNS.ORG
ipa-getkeytab -s ipa.santantoni.duckdns.org \
-p HTTP/sso.$DOMAIN@$(echo "$DOMAIN" | awk '{ print toupper($0) }') \
-k /etc/ipa.keytab
echo "Adding admin user"
#/usr/sbin/sssd -i -d 4 &
#/opt/keycloak/keycloak-12.0.4/bin/federation-ssd-setup.sh
/opt/keycloak/keycloak-12.0.4/bin/add-user-keycloak.sh -r master -u admin -p $KK_ADMIN_PWD
echo "Starting keycloak"
/opt/keycloak/keycloak-12.0.4/bin/standalone.sh -b 0.0.0.0

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>avatar-minio-extension</artifactId>
<groupId>com.github.thomasdarimont.keycloak</groupId>
<version>1.0.1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>avatar-minio-extension-bundle</artifactId>
<packaging>ear</packaging>
<dependencies>
<dependency>
<groupId>com.github.thomasdarimont.keycloak</groupId>
<artifactId>avatar-minio-extension-module</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-ear-plugin</artifactId>
<version>3.0.1</version>
<configuration>
<earSourceDirectory>src/main/resources</earSourceDirectory>
<includeLibInApplicationXml>true</includeLibInApplicationXml>
</configuration>
</plugin>
<plugin>
<groupId>org.wildfly.plugins</groupId>
<artifactId>wildfly-maven-plugin</artifactId>
<configuration>
<skip>false</skip>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<jboss-deployment-structure>
<deployment>
<module-alias name="deployment.avatar-minio-extension-bundle"/>
</deployment>
<sub-deployment name="com.github.thomasdarimont.keycloak-avatar-minio-extension-module-1.0.1.0-SNAPSHOT.jar">
<dependencies>
<module name="javax.servlet.api"/>
<module name="javax.ws.rs.api"/>
<module name="org.jboss.resteasy.resteasy-jaxb-provider"/>
<module name="org.jboss.resteasy.resteasy-jaxrs"/>
<module name="org.jboss.resteasy.resteasy-multipart-provider"/>
<module name="org.keycloak.keycloak-core"/>
<module name="org.keycloak.keycloak-server-spi"/>
<module name="org.keycloak.keycloak-server-spi-private"/>
<module name="org.keycloak.keycloak-services"/>
</dependencies>
</sub-deployment>
</jboss-deployment-structure>

View File

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<application xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/application_7.xsd" version="7">
<display-name>avatar-minio-extension-bundle</display-name>
<module>
<java>com.github.thomasdarimont.keycloak-avatar-minio-extension-module-1.0.1.0-SNAPSHOT.jar</java>
</module>
<module>
<java>io.minio-minio-5.0.6.jar</java>
</module>
<module>
<java>com.google.http-client-google-http-client-xml-1.20.0.jar</java>
</module>
<module>
<java>com.google.http-client-google-http-client-1.20.0.jar</java>
</module>
<module>
<java>org.apache.httpcomponents-httpclient-4.0.1.jar</java>
</module>
<module>
<java>org.apache.httpcomponents-httpcore-4.0.1.jar</java>
</module>
<module>
<java>commons-logging-commons-logging-1.1.1.jar</java>
</module>
<module>
<java>commons-codec-commons-codec-1.3.jar</java>
</module>
<module>
<java>xpp3-xpp3-1.1.4c.jar</java>
</module>
<module>
<java>com.google.guava-guava-25.1-jre.jar</java>
</module>
<module>
<java>org.checkerframework-checker-qual-2.0.0.jar</java>
</module>
<module>
<java>com.google.errorprone-error_prone_annotations-2.1.3.jar</java>
</module>
<module>
<java>com.google.j2objc-j2objc-annotations-1.1.jar</java>
</module>
<module>
<java>org.codehaus.mojo-animal-sniffer-annotations-1.14.jar</java>
</module>
<module>
<java>com.squareup.okhttp3-okhttp-3.7.0.jar</java>
</module>
<module>
<java>com.squareup.okio-okio-1.12.0.jar</java>
</module>
<module>
<java>joda-time-joda-time-2.7.jar</java>
</module>
<module>
<java>com.fasterxml.jackson.core-jackson-annotations-2.9.6.jar</java>
</module>
<module>
<java>com.fasterxml.jackson.core-jackson-core-2.9.6.jar</java>
</module>
<module>
<java>com.fasterxml.jackson.core-jackson-databind-2.9.6.jar</java>
</module>
<module>
<java>com.google.code.findbugs-annotations-3.0.1.jar</java>
</module>
<module>
<java>net.jcip-jcip-annotations-1.0.jar</java>
</module>
<module>
<java>com.google.code.findbugs-jsr305-3.0.1.jar</java>
</module>
</application>

View File

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<application xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/application_7.xsd" version="7">
<display-name>avatar-minio-extension-bundle</display-name>
<module>
<java>com.github.thomasdarimont.keycloak-avatar-minio-extension-module-1.0.1.0-SNAPSHOT.jar</java>
</module>
<module>
<java>io.minio-minio-5.0.6.jar</java>
</module>
<module>
<java>com.google.http-client-google-http-client-xml-1.20.0.jar</java>
</module>
<module>
<java>com.google.http-client-google-http-client-1.20.0.jar</java>
</module>
<module>
<java>org.apache.httpcomponents-httpclient-4.0.1.jar</java>
</module>
<module>
<java>org.apache.httpcomponents-httpcore-4.0.1.jar</java>
</module>
<module>
<java>commons-logging-commons-logging-1.1.1.jar</java>
</module>
<module>
<java>commons-codec-commons-codec-1.3.jar</java>
</module>
<module>
<java>xpp3-xpp3-1.1.4c.jar</java>
</module>
<module>
<java>com.google.guava-guava-25.1-jre.jar</java>
</module>
<module>
<java>org.checkerframework-checker-qual-2.0.0.jar</java>
</module>
<module>
<java>com.google.errorprone-error_prone_annotations-2.1.3.jar</java>
</module>
<module>
<java>com.google.j2objc-j2objc-annotations-1.1.jar</java>
</module>
<module>
<java>org.codehaus.mojo-animal-sniffer-annotations-1.14.jar</java>
</module>
<module>
<java>com.squareup.okhttp3-okhttp-3.7.0.jar</java>
</module>
<module>
<java>com.squareup.okio-okio-1.12.0.jar</java>
</module>
<module>
<java>joda-time-joda-time-2.7.jar</java>
</module>
<module>
<java>com.fasterxml.jackson.core-jackson-annotations-2.9.6.jar</java>
</module>
<module>
<java>com.fasterxml.jackson.core-jackson-core-2.9.6.jar</java>
</module>
<module>
<java>com.fasterxml.jackson.core-jackson-databind-2.9.6.jar</java>
</module>
<module>
<java>com.google.code.findbugs-annotations-3.0.1.jar</java>
</module>
<module>
<java>net.jcip-jcip-annotations-1.0.jar</java>
</module>
<module>
<java>com.google.code.findbugs-jsr305-3.0.1.jar</java>
</module>
</application>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<jboss-deployment-structure>
<deployment>
<module-alias name="deployment.avatar-minio-extension-bundle"/>
</deployment>
<sub-deployment name="com.github.thomasdarimont.keycloak-avatar-minio-extension-module-1.0.1.0-SNAPSHOT.jar">
<dependencies>
<module name="javax.servlet.api"/>
<module name="javax.ws.rs.api"/>
<module name="org.jboss.resteasy.resteasy-jaxb-provider"/>
<module name="org.jboss.resteasy.resteasy-jaxrs"/>
<module name="org.jboss.resteasy.resteasy-multipart-provider"/>
<module name="org.keycloak.keycloak-core"/>
<module name="org.keycloak.keycloak-server-spi"/>
<module name="org.keycloak.keycloak-server-spi-private"/>
<module name="org.keycloak.keycloak-services"/>
</dependencies>
</sub-deployment>
</jboss-deployment-structure>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<jboss-deployment-structure>
<deployment>
<module-alias name="deployment.avatar-minio-extension-bundle"/>
</deployment>
<sub-deployment name="com.github.thomasdarimont.keycloak-avatar-minio-extension-module-1.0.1.0-SNAPSHOT.jar">
<dependencies>
<module name="javax.servlet.api"/>
<module name="javax.ws.rs.api"/>
<module name="org.jboss.resteasy.resteasy-jaxb-provider"/>
<module name="org.jboss.resteasy.resteasy-jaxrs"/>
<module name="org.jboss.resteasy.resteasy-multipart-provider"/>
<module name="org.keycloak.keycloak-core"/>
<module name="org.keycloak.keycloak-server-spi"/>
<module name="org.keycloak.keycloak-server-spi-private"/>
<module name="org.keycloak.keycloak-services"/>
</dependencies>
</sub-deployment>
</jboss-deployment-structure>

View File

@ -0,0 +1,4 @@
#Created by Apache Maven 3.6.0
groupId=com.github.thomasdarimont.keycloak
artifactId=avatar-minio-extension-bundle
version=1.0.1.0-SNAPSHOT

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>avatar-minio-extension</artifactId>
<groupId>com.github.thomasdarimont.keycloak</groupId>
<version>1.0.1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>avatar-minio-extension-module</artifactId>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,53 @@
package com.github.thomasdarimont.keycloak.avatar;
import com.github.thomasdarimont.keycloak.avatar.storage.AvatarStorageProvider;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.StreamingOutput;
import org.keycloak.models.KeycloakSession;
public abstract class AbstractAvatarResource {
protected static final String AVATAR_IMAGE_PARAMETER = "image";
protected KeycloakSession session;
public AbstractAvatarResource(KeycloakSession session) {
this.session = session;
}
public AvatarStorageProvider getAvatarStorageProvider() {
return lookupAvatarStorageProvider(session);
}
protected AvatarStorageProvider lookupAvatarStorageProvider(KeycloakSession keycloakSession) {
return keycloakSession.getProvider(AvatarStorageProvider.class);
}
protected Response badRequest() {
return Response.status(Response.Status.BAD_REQUEST).build();
}
protected void saveUserImage(String realmName, String userId, InputStream imageInputStream) {
getAvatarStorageProvider().saveAvatarImage(realmName, userId, imageInputStream);
}
protected StreamingOutput fetchUserImage(String realmId, String userId) {
return output -> copyStream(getAvatarStorageProvider().loadAvatarImage(realmId, userId), output);
}
private void copyStream(InputStream in, OutputStream out) throws IOException {
byte[] buffer = new byte[16384];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
out.flush();
}
}

View File

@ -0,0 +1,171 @@
package com.github.thomasdarimont.keycloak.avatar;
import com.github.thomasdarimont.keycloak.avatar.storage.AvatarStorageProvider;
import java.io.InputStream;
import javax.ws.rs.Consumes;
import javax.ws.rs.ForbiddenException;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import org.jboss.logging.Logger;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.plugins.providers.multipart.MultipartFormDataInput;
import org.jboss.resteasy.spi.NotFoundException;
import org.jboss.resteasy.spi.UnauthorizedException;
import org.keycloak.common.ClientConnection;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.resources.admin.AdminAuth;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
import org.keycloak.services.resources.admin.permissions.AdminPermissions;
public class AvatarAdminResource extends AbstractAvatarResource {
private static final Logger logger = Logger.getLogger(AvatarAdminResource.class);
private AdminPermissionEvaluator realmAuth;
@Context
private AvatarStorageProvider avatarStorageProvider;
private AppAuthManager authManager;
private TokenManager tokenManager;
@Context
private HttpHeaders httpHeaders;
@Context
private ClientConnection clientConnection;
private AdminAuth auth;
public AvatarAdminResource(KeycloakSession session) {
super(session);
authManager = new AppAuthManager();
tokenManager = new TokenManager();
}
public void init() {
RealmModel realm = session.getContext().getRealm();
auth = authenticateRealmAdminRequest();
RealmManager realmManager = new RealmManager(session);
if (realm == null) throw new NotFoundException("Realm not found.");
if (!auth.getRealm().equals(realmManager.getKeycloakAdminstrationRealm())
&& !auth.getRealm().equals(realm)) {
throw new org.keycloak.services.ForbiddenException();
}
realmAuth = AdminPermissions.evaluator(session, realm, auth);
session.getContext().setRealm(realm);
}
@GET
@Path("/{user_id}")
@Produces({"image/png", "image/jpeg", "image/gif"})
public Response downloadUserAvatarImage(@PathParam("user_id") String userId) {
try {
canViewUsers();
return Response.ok(fetchUserImage(session.getContext().getRealm().getName(), userId)).build();
} catch (ForbiddenException e) {
return Response.status(Response.Status.FORBIDDEN).entity(e.getMessage()).build();
} catch (Exception e) {
logger.error("error getting user avatar", e);
return Response.serverError().entity(e.getMessage()).build();
}
}
@POST
@NoCache
@Path("/{user_id}")
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Response uploadUserAvatarImage(@PathParam("user_id") String userId, MultipartFormDataInput input) {
try {
if (auth == null) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
canManageUsers();
String realmName = session.getContext().getRealm().getName();
InputStream imageInputStream = input.getFormDataPart(AVATAR_IMAGE_PARAMETER, InputStream.class, null);
saveUserImage(realmName, userId, imageInputStream);
} catch (ForbiddenException e) {
return Response.status(Response.Status.FORBIDDEN).entity(e.getMessage()).build();
} catch (Exception e) {
logger.error("error saving user avatar", e);
return Response.serverError().entity(e.getMessage()).build();
}
return Response.ok().build();
}
protected AdminAuth authenticateRealmAdminRequest() {
String tokenString = authManager.extractAuthorizationHeaderToken(httpHeaders);
MultivaluedMap<String, String> queryParameters = session.getContext().getUri().getQueryParameters();
if (tokenString == null && queryParameters.containsKey("access_token")) {
tokenString = queryParameters.getFirst("access_token");
}
if (tokenString == null) throw new UnauthorizedException("Bearer");
AccessToken token;
try {
JWSInput input = new JWSInput(tokenString);
token = input.readJsonContent(AccessToken.class);
} catch (JWSInputException e) {
throw new UnauthorizedException("Bearer token format error");
}
String realmName = token.getIssuer().substring(token.getIssuer().lastIndexOf('/') + 1);
RealmManager realmManager = new RealmManager(session);
RealmModel realm = realmManager.getRealmByName(realmName);
if (realm == null) {
throw new UnauthorizedException("Unknown realm in token");
}
session.getContext().setRealm(realm);
AuthenticationManager.AuthResult authResult = authManager.authenticateBearerToken(tokenString, session, realm, session.getContext().getUri(), clientConnection, httpHeaders);
if (authResult == null) {
logger.debug("Token not valid");
throw new UnauthorizedException("Bearer");
}
ClientModel client = realm.getClientByClientId(token.getIssuedFor());
if (client == null) {
throw new NotFoundException("Could not find client for authorization");
}
return new AdminAuth(realm, authResult.getToken(), authResult.getUser(), client);
}
private void canViewUsers() {
if (!realmAuth.users().canView()) {
logger.info("user does not have permission to view users");
throw new ForbiddenException("user does not have permission to view users");
}
}
private void canManageUsers() {
if (!realmAuth.users().canManage()) {
logger.info("user does not have permission to manage users");
throw new ForbiddenException("user does not have permission to manage users");
}
}
}

View File

@ -0,0 +1,120 @@
package com.github.thomasdarimont.keycloak.avatar;
import javax.ws.rs.core.MultivaluedMap;
import org.jboss.logging.Logger;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.plugins.providers.multipart.MultipartFormDataInput;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.resources.RealmsResource;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Objects;
public class AvatarResource extends AbstractAvatarResource {
private static final Logger log = Logger.getLogger(AvatarResource.class);
public static final String STATE_CHECKER_ATTRIBUTE = "state_checker";
public static final String STATE_CHECKER_PARAMETER = "stateChecker";
private final AuthenticationManager.AuthResult auth;
public AvatarResource(KeycloakSession session) {
super(session);
this.auth = resolveAuthentication(session);
}
private AuthenticationManager.AuthResult resolveAuthentication(KeycloakSession keycloakSession) {
AppAuthManager appAuthManager = new AppAuthManager();
RealmModel realm = keycloakSession.getContext().getRealm();
AuthenticationManager.AuthResult authResult = appAuthManager.authenticateIdentityCookie(keycloakSession, realm);
if (authResult != null) {
return authResult;
}
return null;
}
@Path("/admin")
public AvatarAdminResource admin() {
AvatarAdminResource service = new AvatarAdminResource(session);
ResteasyProviderFactory.getInstance().injectProperties(service);
service.init();
return service;
}
@GET
@Produces({"image/png", "image/jpeg", "image/gif"})
public Response downloadCurrentUserAvatarImage() {
if (auth == null) {
return badRequest();
}
String realmName = auth.getSession().getRealm().getName();
String userId = auth.getUser().getId();
return Response.ok(fetchUserImage(realmName, userId)).build();
}
@POST
@NoCache
@Consumes(MediaType.MULTIPART_FORM_DATA)
public Response uploadCurrentUserAvatarImage(MultipartFormDataInput input, @Context UriInfo uriInfo) {
if (auth == null) {
return badRequest();
}
if (!isValidStateChecker(input)) {
return badRequest();
}
try {
InputStream imageInputStream = input.getFormDataPart(AVATAR_IMAGE_PARAMETER, InputStream.class, null);
String realmName = auth.getSession().getRealm().getName();
String userId = auth.getUser().getId();
saveUserImage(realmName, userId, imageInputStream);
if (uriInfo.getQueryParameters().containsKey("account")) {
return Response.seeOther(RealmsResource.accountUrl(session.getContext().getUri().getBaseUriBuilder()).build(realmName)).build();
}
return Response.ok().build();
} catch (Exception ex) {
return Response.serverError().build();
}
}
private boolean isValidStateChecker(MultipartFormDataInput input) {
try {
String actualStateChecker = input.getFormDataPart(STATE_CHECKER_PARAMETER, String.class, null);
String requiredStateChecker = (String) session.getAttribute(STATE_CHECKER_ATTRIBUTE);
return Objects.equals(requiredStateChecker, actualStateChecker);
} catch (Exception ex) {
return false;
}
}
}

View File

@ -0,0 +1,24 @@
package com.github.thomasdarimont.keycloak.avatar;
import org.keycloak.models.KeycloakSession;
import org.keycloak.services.resource.RealmResourceProvider;
public class AvatarResourceProvider implements RealmResourceProvider {
private final KeycloakSession keycloakSession;
public AvatarResourceProvider(KeycloakSession keycloakSession) {
this.keycloakSession = keycloakSession;
}
@Override
public Object getResource() {
return new AvatarResource(keycloakSession);
}
@Override
public void close() {
// NOOP
}
}

View File

@ -0,0 +1,40 @@
package com.github.thomasdarimont.keycloak.avatar;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resource.RealmResourceProviderFactory;
import static org.keycloak.Config.Scope;
public class AvatarResourceProviderFactory implements RealmResourceProviderFactory {
private AvatarResourceProvider avatarResourceProvider;
@Override
public RealmResourceProvider create(KeycloakSession keycloakSession) {
if (avatarResourceProvider == null) {
avatarResourceProvider = new AvatarResourceProvider(keycloakSession);
}
return avatarResourceProvider;
}
@Override
public void init(Scope scope) {
}
@Override
public void postInit(KeycloakSessionFactory keycloakSessionFactory) {
}
@Override
public void close() {
// NOOP
}
@Override
public String getId() {
return "avatar-provider";
}
}

View File

@ -0,0 +1,12 @@
package com.github.thomasdarimont.keycloak.avatar.storage;
import org.keycloak.provider.Provider;
import java.io.InputStream;
public interface AvatarStorageProvider extends Provider {
void saveAvatarImage(String realmName, String userId, InputStream input);
InputStream loadAvatarImage(String realmId, String userId);
}

View File

@ -0,0 +1,6 @@
package com.github.thomasdarimont.keycloak.avatar.storage;
import org.keycloak.provider.ProviderFactory;
public interface AvatarStorageProviderFactory extends ProviderFactory<AvatarStorageProvider> {
}

View File

@ -0,0 +1,28 @@
package com.github.thomasdarimont.keycloak.avatar.storage;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class AvatarStorageProviderSpi implements Spi {
@Override
public boolean isInternal() {
return false;
}
@Override
public String getName() {
return "avatar-storage";
}
@Override
public Class<? extends Provider> getProviderClass() {
return AvatarStorageProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return AvatarStorageProviderFactory.class;
}
}

View File

@ -0,0 +1,43 @@
package com.github.thomasdarimont.keycloak.avatar.storage.minio;
import com.github.thomasdarimont.keycloak.avatar.storage.AvatarStorageProvider;
import lombok.extern.jbosslog.JBossLog;
import java.io.InputStream;
@JBossLog
public class MinioAvatarStorageProvider implements AvatarStorageProvider {
private final MinioTemplate minioTemplate;
public MinioAvatarStorageProvider(MinioConfig minioConfig) {
this.minioTemplate = new MinioTemplate(minioConfig);
}
@Override
public void saveAvatarImage(String realmName, String userId, InputStream input) {
String bucketName = minioTemplate.getBucketName(realmName);
minioTemplate.ensureBucketExists(bucketName);
minioTemplate.execute(minioClient -> {
minioClient.putObject(bucketName, userId, input, "image/png");
return null;
});
}
@Override
public InputStream loadAvatarImage(String realmName, String userId) {
String bucketName = minioTemplate.getBucketName(realmName);
return minioTemplate.execute(minioClient -> minioClient.getObject(bucketName, userId));
}
@Override
public void close() {
// NOOP
}
}

View File

@ -0,0 +1,49 @@
package com.github.thomasdarimont.keycloak.avatar.storage.minio;
import com.github.thomasdarimont.keycloak.avatar.storage.AvatarStorageProvider;
import com.github.thomasdarimont.keycloak.avatar.storage.AvatarStorageProviderFactory;
import lombok.extern.jbosslog.JBossLog;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
@JBossLog
public class MinioAvatarStorageProviderFactory implements AvatarStorageProviderFactory {
//TODO remove default settings
private static final String DEFAULT_SERVER_URL = "http://isard-sso-avatars:9000";
private static final String DEFAULT_ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE";
private static final String DEFAULT_SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
private MinioConfig minioConfig;
@Override
public AvatarStorageProvider create(KeycloakSession session) {
return new MinioAvatarStorageProvider(minioConfig);
}
@Override
public void init(Config.Scope config) {
String serverUrl = config.get("server-url", DEFAULT_SERVER_URL);
String accessKey = config.get("access-key", DEFAULT_ACCESS_KEY);
String secretKey = config.get("secret-key", DEFAULT_SECRET_KEY);
this.minioConfig = new MinioConfig(serverUrl, accessKey, secretKey);
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// NOOP
}
@Override
public void close() {
// NOOP
}
@Override
public String getId() {
return "avatar-storage-minio";
}
}

View File

@ -0,0 +1,9 @@
package com.github.thomasdarimont.keycloak.avatar.storage.minio;
import io.minio.MinioClient;
public interface MinioCallback<T> {
T doInMinio(MinioClient minioClient) throws Exception;
}

View File

@ -0,0 +1,15 @@
package com.github.thomasdarimont.keycloak.avatar.storage.minio;
import lombok.Data;
@Data
public class MinioConfig {
private final String serverUrl;
private final String accessKey;
private final String secretKey;
private String defaultBucketSuffix = "-avatars";
}

View File

@ -0,0 +1,57 @@
package com.github.thomasdarimont.keycloak.avatar.storage.minio;
import io.minio.MinioClient;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.jbosslog.JBossLog;
import okhttp3.OkHttpClient;
@JBossLog
public class MinioTemplate {
private static final int TIMEOUT_SECONDS = 15;
private final MinioConfig minioConfig;
private OkHttpClient httpClient;
public MinioTemplate(MinioConfig minioConfig) {
this.minioConfig = minioConfig;
httpClient = new OkHttpClient.Builder()
.connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build();
}
public <T> T execute(MinioCallback<T> callback) {
try {
MinioClient minioClient = new MinioClient(minioConfig.getServerUrl(), 0, minioConfig.getAccessKey(),
minioConfig.getSecretKey(), "", false, httpClient);
return callback.doInMinio(minioClient);
} catch (Exception mex) {
throw new RuntimeException(mex);
}
}
public void ensureBucketExists(String bucketName) {
execute(minioClient -> {
boolean exists = minioClient.bucketExists(bucketName);
if (exists) {
log.debugf("Bucket: %s already exists", bucketName);
} else {
minioClient.makeBucket(bucketName);
}
return null;
});
}
public String getBucketName(String realmName) {
return realmName + minioConfig.getDefaultBucketSuffix();
}
}

View File

@ -0,0 +1,11 @@
{
"themes": [
{
"name": "account-avatar",
"types": [
"account",
"admin"
]
}
]
}

View File

@ -0,0 +1 @@
com.github.thomasdarimont.keycloak.avatar.storage.AvatarStorageProviderSpi

View File

@ -0,0 +1,93 @@
<#import "template.ftl" as layout>
<@layout.mainLayout active='account' bodyClass='user'; section>
<div class="row">
<div class="col-md-10">
<h2>${msg("editAccountHtmlTitle")}</h2>
</div>
<div class="col-md-2 subtitle">
<span class="subtitle"><span class="required">*</span> ${msg("requiredFields")}</span>
</div>
</div>
<form action="${url.accountUrl}" class="form-horizontal" method="post">
<input type="hidden" id="stateChecker" name="stateChecker" value="${stateChecker}">
<#if !realm.registrationEmailAsUsername>
<div class="form-group ${messagesPerField.printIfExists('username','has-error')}">
<div class="col-sm-2 col-md-2">
<label for="username" class="control-label">${msg("username")}</label> <#if realm.editUsernameAllowed><span class="required">*</span></#if>
</div>
<div class="col-sm-10 col-md-10">
<input type="text" class="form-control" id="username" name="username" <#if !realm.editUsernameAllowed>disabled="disabled"</#if> value="${(account.username!'')}"/>
</div>
</div>
</#if>
<div class="form-group ${messagesPerField.printIfExists('email','has-error')}">
<div class="col-sm-2 col-md-2">
<label for="email" class="control-label">${msg("email")}</label> <span class="required">*</span>
</div>
<div class="col-sm-10 col-md-10">
<input type="text" class="form-control" id="email" name="email" autofocus value="${(account.email!'')}"/>
</div>
</div>
<div class="form-group ${messagesPerField.printIfExists('firstName','has-error')}">
<div class="col-sm-2 col-md-2">
<label for="firstName" class="control-label">${msg("firstName")}</label> <span class="required">*</span>
</div>
<div class="col-sm-10 col-md-10">
<input type="text" class="form-control" id="firstName" name="firstName" value="${(account.firstName!'')}"/>
</div>
</div>
<div class="form-group ${messagesPerField.printIfExists('lastName','has-error')}">
<div class="col-sm-2 col-md-2">
<label for="lastName" class="control-label">${msg("lastName")}</label> <span class="required">*</span>
</div>
<div class="col-sm-10 col-md-10">
<input type="text" class="form-control" id="lastName" name="lastName" value="${(account.lastName!'')}"/>
</div>
</div>
<div class="form-group">
<div id="kc-form-buttons" class="col-md-offset-2 col-md-10 submit">
<div class="">
<#if url.referrerURI??><a href="${url.referrerURI}">${kcSanitize(msg("backToApplication")?no_esc)}</a></#if>
<button type="submit" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" name="submitAction" value="Save">${msg("doSave")}</button>
<button type="submit" class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!}" name="submitAction" value="Cancel">${msg("doCancel")}</button>
</div>
</div>
</div>
</form>
<div class="row">
<div class="col-md-10">
<h2>${msg("changeAvatarHtmlTitle")}</h2>
</div>
</div>
<#assign avatarUrl = url.accountUrl?replace("^(.*)(/account/?)(\\?(.*))?$", "$1/avatar-provider/?account&$4", 'r') />
<form action="${avatarUrl}" class="form-horizontal" method="post" enctype="multipart/form-data">
<img src="${avatarUrl}" style="max-width:200px;" >
<input type="file" id="avatar" name="image">
<input type="hidden" name="stateChecker" value="${stateChecker}">
<div class="form-group">
<div id="kc-form-buttons" class="col-md-offset-2 col-md-10 submit">
<div class="">
<button type="submit" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" name="submitAction" value="Save">${msg("doSave")}</button>
</div>
</div>
</div>
</form>
</@layout.mainLayout>

View File

@ -0,0 +1,26 @@
module.service('UserAvatar', function(Auth) {
this.url = function(user, realm) {
return authUrl + '/realms/' + realm.realm + '/avatar-provider/admin/' + user.id + "?access_token=" + Auth.authz.token + "&" + + new Date().getTime();
}
});
module.controller('UserAvatarCtrl', function($scope, $http, Notifications, UserAvatar) {
$scope.avatarUrl = UserAvatar.url($scope.user, $scope.realm);
$scope.uploadAvatar = function(files) {
var fd = new FormData();
//Take the first selected file
fd.append("image", files[0]);
$http.post($scope.avatarUrl, fd, {
headers: {'Content-Type': undefined },
transformRequest: angular.identity
}).then(function() {
Notifications.success("Your changes have been saved to the user.");
$scope.avatarUrl = UserAvatar.url($scope.user, $scope.realm);
}, function(error) {
console.error(error);
Notifications.error("Could not save the avatar");
});
}
});

View File

@ -0,0 +1,168 @@
<div class="col-sm-9 col-md-10 col-sm-push-3 col-md-push-2">
<ol class="breadcrumb">
<li><a href="#/realms/{{realm.realm}}/users">{{:: 'users' | translate}}</a></li>
<li data-ng-hide="create">{{user.username}}</li>
<li data-ng-show="create">{{:: 'add-user' | translate}}</li>
</ol>
<kc-tabs-user></kc-tabs-user>
<form class="form-horizontal" name="userForm" novalidate kc-read-only="!create && !user.access.manage">
<fieldset class="border-top">
<div class="form-group">
<label class="col-md-2 control-label"for="id">{{:: 'id' | translate}}</label>
<div class="col-md-6">
<input class="form-control" type="text" id="id" name="id" data-ng-model="user.id" autofocus data-ng-readonly="true">
</div>
</div>
<div class="form-group">
<label class="col-md-2 control-label"for="id">{{:: 'created-at' | translate}}</label>
<div class="col-md-6">
{{user.createdTimestamp|date:'shortDate'}}&nbsp;{{user.createdTimestamp|date:'mediumTime'}}
</div>
</div>
<div class="form-group">
<label class="col-md-2 control-label"for="username">{{:: 'username' | translate}} <span class="required" data-ng-show="create">*</span></label>
<div class="col-md-6">
<!-- Characters >,<,/,\ are forbidden in username -->
<input class="form-control" type="text" id="username" name="username" data-ng-model="user.username" autofocus
required ng-pattern="/^[^\<\>\\\/]*$/" data-ng-readonly="!editUsername">
</div>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="email">{{:: 'email' | translate}}</label>
<div class="col-md-6">
<input class="form-control" type="email" name="email" id="email"
data-ng-model="user.email">
</div>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="firstName">{{:: 'first-name' | translate}}</label>
<div class="col-md-6">
<input class="form-control" type="text" name="firstName" id="firstName"
data-ng-model="user.firstName">
</div>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="lastName">{{:: 'last-name' | translate}}</label>
<div class="col-md-6">
<input class="form-control" type="text" name="lastName" id="lastName"
data-ng-model="user.lastName">
</div>
</div>
<div class="form-group clearfix block">
<label class="col-md-2 control-label" for="userEnabled">{{:: 'user-enabled' | translate}}</label>
<div class="col-md-6">
<input ng-model="user.enabled" name="userEnabled" id="userEnabled" ng-disabled="!create && !user.access.manage" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
</div>
<kc-tooltip>{{:: 'user-enabled.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix block" data-ng-show="realm.bruteForceProtected && !create">
<label class="col-md-2 control-label" for="temporarilyDisabled">{{:: 'user-temporarily-locked' | translate}}</label>
<div class="col-md-1">
<input ng-model="temporarilyDisabled" name="temporarilyDisabled" id="temporarilyDisabled" data-ng-readonly="true" data-ng-disabled="true" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
</div>
<kc-tooltip>{{:: 'user-temporarily-locked.tooltip' | translate}}</kc-tooltip>
<div class="col-sm-2">
<button type="submit" data-ng-click="unlockUser()" data-ng-show="temporarilyDisabled" class="btn btn-default">{{:: 'unlock-user' | translate}}</button>
</div>
</div>
<div class="form-group clearfix block" data-ng-show="!create && user.federationLink">
<label class="col-md-2 control-label">{{:: 'federation-link' | translate}}</label>
<div class="col-md-6">
<a href="{{federationLink}}">{{federationLinkName}}</a>
</div>
<kc-tooltip>{{:: 'user-link.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix block" data-ng-show="!create && user.origin">
<label class="col-md-2 control-label">{{:: 'user-origin-link' | translate}}</label>
<div class="col-md-6">
<a href="{{originLink}}">{{originName}}</a>
</div>
<kc-tooltip>{{:: 'user-origin.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix block">
<label class="col-md-2 control-label" for="emailVerified">{{:: 'email-verified' | translate}}</label>
<div class="col-md-6">
<input ng-model="user.emailVerified" name="emailVerified" id="emailVerified" ng-disabled="!create && !user.access.manage" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
</div>
<kc-tooltip>{{:: 'email-verified.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix">
<label class="col-md-2 control-label" for="reqActions">{{:: 'required-user-actions' | translate}}</label>
<div class="col-md-6">
<select ui-select2 id="reqActions" ng-model="user.requiredActions" data-placeholder="{{:: 'select-an-action.placeholder' | translate}}" multiple>
<option ng-repeat="action in userReqActionList" value="{{action.alias}}">{{action.name}}</option>
</select>
</div>
<kc-tooltip>{{:: 'required-user-actions.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix" data-ng-if="realm.internationalizationEnabled">
<label class="col-md-2 control-label" for="locale">{{:: 'locale' | translate}}</label>
<div class="col-md-6">
<div>
<select class="form-control" id="locale"
ng-model="user.attributes.locale"
ng-options="o as o for o in realm.supportedLocales">
<option value="" disabled selected>{{:: 'select-one.placeholder' | translate}}</option>
</select>
</div>
</div>
</div>
<div class="form-group clearfix" data-ng-hide="create || !access.impersonation">
<label class="col-md-2 control-label" for="impersonate">{{:: 'impersonate-user' | translate}}</label>
<div class="col-md-6">
<button id="impersonate" data-ng-show="access.impersonation" kc-read-only-ignore class="btn btn-default" data-ng-click="impersonate()">{{:: 'impersonate' | translate}}</button>
</div>
<kc-tooltip>{{:: 'impersonate-user.tooltip' | translate}}</kc-tooltip>
</div>
</fieldset>
<div class="form-group">
<div class="col-md-10 col-md-offset-2" data-ng-show="create && access.manageUsers">
<button kc-save data-ng-show="changed">{{:: 'save' | translate}}</button>
<button kc-cancel data-ng-click="cancel()">{{:: 'cancel' | translate}}</button>
</div>
<div class="col-md-10 col-md-offset-2" data-ng-show="!create && user.access.manage">
<button kc-save data-ng-disabled="!changed">{{:: 'save' | translate}}</button>
<button kc-reset data-ng-disabled="!changed">{{:: 'cancel' | translate}}</button>
</div>
</div>
</form>
<form class="form-horizontal" ng-controller="UserAvatarCtrl" novalidate>
<fieldset class="border-top">
<legend><span class="text">Avatar</span></legend>
<div class="form-group">
<div class="col-md-10 col-md-offset-2">
<img style="max-width:600px;" src="{{ avatarUrl }}" />
</div>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="image">Upload</label>
<div class="col-md-6">
<input class="form-control" type="file" name="image" id="image" onchange="angular.element(this).scope().uploadAvatar(this.files)" />
</div>
</div>
</fieldset>
</form>
</div>
<kc-menu></kc-menu>

View File

@ -0,0 +1,11 @@
{
"themes": [
{
"name": "account-avatar",
"types": [
"account",
"admin"
]
}
]
}

View File

@ -0,0 +1 @@
com.github.thomasdarimont.keycloak.avatar.storage.AvatarStorageProviderSpi

Some files were not shown because too many files have changed in this diff Show More