From 51371516b6286686f38330b39c03633deaf3e86a Mon Sep 17 00:00:00 2001 From: davrot Date: Fri, 4 Apr 2025 22:37:43 +0200 Subject: [PATCH] Upload files to "/" --- docker/backup/copy_keys_over.sh | 2 + docker/backup/make_backup.sh | 7 + docker/backup/make_keys.sh | 2 + docker/check_docker.sh | 16 ++ docker/compose/compose.yaml | 47 ++++ docker/compose/down.sh | 2 + docker/compose/down_nginx.sh | 2 + docker/compose/exec_keycloakpostgres.sh | 2 + docker/compose/exec_keycloakserver.sh | 2 + docker/compose/exec_nginx.sh | 2 + docker/compose/externals/Dockerfile | 23 ++ docker/compose/externals/compose.yaml | 29 +++ docker/compose/externals/data/add_user.py | 74 ++++++ .../compose/externals/data/blocked_users.json | 6 + docker/compose/externals/data/config.json | 15 ++ docker/compose/externals/data/main.py | 117 +++++++++ .../compose/externals/data/process_emails.py | 30 +++ .../externals/data/static/UNI_Logo.svg | 50 ++++ .../externals/data/templates/post.html | 100 ++++++++ docker/compose/externals/data/wsgi.py | 5 + docker/compose/externals/down.sh | 2 + docker/compose/externals/logs.sh | 2 + docker/compose/externals/make_image.sh | 2 + docker/compose/externals/up.sh | 3 + docker/compose/keycloakpostgres/backup.sh | 2 + docker/compose/keycloakpostgres/compose.yaml | 26 ++ docker/compose/keycloakserver/compose.yaml | 51 ++++ .../keycloakserver/custom/login/buttons.ftl | 21 ++ .../keycloakserver/custom/login/code.ftl | 20 ++ .../custom/login/delete-account-confirm.ftl | 40 +++ .../custom/login/delete-credential.ftl | 21 ++ .../keycloakserver/custom/login/field.ftl | 103 ++++++++ .../custom/login/login-config-totp.ftl | 123 +++++++++ .../custom/login/login-oauth-grant.ftl | 59 +++++ .../keycloakserver/custom/login/login-otp.ftl | 45 ++++ .../custom/login/login-password.ftl | 19 ++ .../login-recovery-authn-code-config.ftl | 180 +++++++++++++ .../login/login-recovery-authn-code-input.ftl | 15 ++ .../custom/login/login-reset-password.ftl | 27 ++ .../custom/login/login-update-password.ftl | 32 +++ .../custom/login/login-username.ftl | 55 ++++ .../keycloakserver/custom/login/login.ftl | 51 ++++ .../custom/login/password-commons.ftl | 12 + .../custom/login/password-validation.ftl | 51 ++++ .../custom/login/register-commons.ftl | 27 ++ .../keycloakserver/custom/login/register.ftl | 68 +++++ .../custom/login/resources/css/styles.css | 211 ++++++++++++++++ .../custom/login/resources/img/UNI_Logo.svg | 50 ++++ .../login/resources/js/password-policy.js | 53 ++++ .../custom/login/resources/js/userProfile.js | 72 ++++++ .../custom/login/select-authenticator.ftl | 41 +++ .../custom/login/social-providers.ftl | 39 +++ .../keycloakserver/custom/login/template.ftl | 236 ++++++++++++++++++ .../keycloakserver/custom/login/terms.ftl | 21 ++ .../custom/login/theme.properties | 110 ++++++++ .../custom/login/user-profile-commons.ftl | 219 ++++++++++++++++ .../custom/login/webauthn-authenticate.ftl | 118 +++++++++ .../custom/login/webauthn-register.ftl | 61 +++++ docker/compose/logs_all.sh | 2 + docker/compose/logs_keycloakpostgres.sh | 2 + docker/compose/logs_keycloakserver.sh | 2 + docker/compose/logs_nginx.sh | 2 + docker/compose/nginx/compose.yaml | 30 +++ docker/compose/nginx/nginx.conf | 54 ++++ docker/compose/register/Dockerfile | 23 ++ docker/compose/register/compose.yaml | 29 +++ docker/compose/register/data/add_user.py | 74 ++++++ .../register/data/allowed_domains.json | 6 + .../compose/register/data/blocked_users.json | 6 + docker/compose/register/data/config.json | 9 + docker/compose/register/data/main.py | 65 +++++ .../compose/register/data/process_emails.py | 43 ++++ docker/compose/register/data/run.sh | 2 + docker/compose/register/data/secret_key.json | 4 + .../compose/register/data/static/UNI_Logo.svg | 50 ++++ .../compose/register/data/templates/post.html | 117 +++++++++ docker/compose/register/data/wsgi.py | 5 + docker/compose/register/down.sh | 2 + docker/compose/register/logs.sh | 2 + docker/compose/register/make_image.sh | 2 + docker/compose/register/up.sh | 3 + docker/compose/up.sh | 13 + docker/compose/up_nginx.sh | 2 + 83 files changed, 3370 insertions(+) create mode 100644 docker/backup/copy_keys_over.sh create mode 100644 docker/backup/make_backup.sh create mode 100644 docker/backup/make_keys.sh create mode 100644 docker/check_docker.sh create mode 100644 docker/compose/compose.yaml create mode 100644 docker/compose/down.sh create mode 100644 docker/compose/down_nginx.sh create mode 100644 docker/compose/exec_keycloakpostgres.sh create mode 100644 docker/compose/exec_keycloakserver.sh create mode 100644 docker/compose/exec_nginx.sh create mode 100644 docker/compose/externals/Dockerfile create mode 100644 docker/compose/externals/compose.yaml create mode 100644 docker/compose/externals/data/add_user.py create mode 100644 docker/compose/externals/data/blocked_users.json create mode 100644 docker/compose/externals/data/config.json create mode 100644 docker/compose/externals/data/main.py create mode 100644 docker/compose/externals/data/process_emails.py create mode 100644 docker/compose/externals/data/static/UNI_Logo.svg create mode 100644 docker/compose/externals/data/templates/post.html create mode 100644 docker/compose/externals/data/wsgi.py create mode 100644 docker/compose/externals/down.sh create mode 100644 docker/compose/externals/logs.sh create mode 100644 docker/compose/externals/make_image.sh create mode 100644 docker/compose/externals/up.sh create mode 100644 docker/compose/keycloakpostgres/backup.sh create mode 100644 docker/compose/keycloakpostgres/compose.yaml create mode 100644 docker/compose/keycloakserver/compose.yaml create mode 100644 docker/compose/keycloakserver/custom/login/buttons.ftl create mode 100644 docker/compose/keycloakserver/custom/login/code.ftl create mode 100644 docker/compose/keycloakserver/custom/login/delete-account-confirm.ftl create mode 100644 docker/compose/keycloakserver/custom/login/delete-credential.ftl create mode 100644 docker/compose/keycloakserver/custom/login/field.ftl create mode 100644 docker/compose/keycloakserver/custom/login/login-config-totp.ftl create mode 100644 docker/compose/keycloakserver/custom/login/login-oauth-grant.ftl create mode 100644 docker/compose/keycloakserver/custom/login/login-otp.ftl create mode 100644 docker/compose/keycloakserver/custom/login/login-password.ftl create mode 100644 docker/compose/keycloakserver/custom/login/login-recovery-authn-code-config.ftl create mode 100644 docker/compose/keycloakserver/custom/login/login-recovery-authn-code-input.ftl create mode 100644 docker/compose/keycloakserver/custom/login/login-reset-password.ftl create mode 100644 docker/compose/keycloakserver/custom/login/login-update-password.ftl create mode 100644 docker/compose/keycloakserver/custom/login/login-username.ftl create mode 100644 docker/compose/keycloakserver/custom/login/login.ftl create mode 100644 docker/compose/keycloakserver/custom/login/password-commons.ftl create mode 100644 docker/compose/keycloakserver/custom/login/password-validation.ftl create mode 100644 docker/compose/keycloakserver/custom/login/register-commons.ftl create mode 100644 docker/compose/keycloakserver/custom/login/register.ftl create mode 100644 docker/compose/keycloakserver/custom/login/resources/css/styles.css create mode 100644 docker/compose/keycloakserver/custom/login/resources/img/UNI_Logo.svg create mode 100644 docker/compose/keycloakserver/custom/login/resources/js/password-policy.js create mode 100644 docker/compose/keycloakserver/custom/login/resources/js/userProfile.js create mode 100644 docker/compose/keycloakserver/custom/login/select-authenticator.ftl create mode 100644 docker/compose/keycloakserver/custom/login/social-providers.ftl create mode 100644 docker/compose/keycloakserver/custom/login/template.ftl create mode 100644 docker/compose/keycloakserver/custom/login/terms.ftl create mode 100644 docker/compose/keycloakserver/custom/login/theme.properties create mode 100644 docker/compose/keycloakserver/custom/login/user-profile-commons.ftl create mode 100644 docker/compose/keycloakserver/custom/login/webauthn-authenticate.ftl create mode 100644 docker/compose/keycloakserver/custom/login/webauthn-register.ftl create mode 100644 docker/compose/logs_all.sh create mode 100644 docker/compose/logs_keycloakpostgres.sh create mode 100644 docker/compose/logs_keycloakserver.sh create mode 100644 docker/compose/logs_nginx.sh create mode 100644 docker/compose/nginx/compose.yaml create mode 100644 docker/compose/nginx/nginx.conf create mode 100644 docker/compose/register/Dockerfile create mode 100644 docker/compose/register/compose.yaml create mode 100644 docker/compose/register/data/add_user.py create mode 100644 docker/compose/register/data/allowed_domains.json create mode 100644 docker/compose/register/data/blocked_users.json create mode 100644 docker/compose/register/data/config.json create mode 100644 docker/compose/register/data/main.py create mode 100644 docker/compose/register/data/process_emails.py create mode 100644 docker/compose/register/data/run.sh create mode 100644 docker/compose/register/data/secret_key.json create mode 100644 docker/compose/register/data/static/UNI_Logo.svg create mode 100644 docker/compose/register/data/templates/post.html create mode 100644 docker/compose/register/data/wsgi.py create mode 100644 docker/compose/register/down.sh create mode 100644 docker/compose/register/logs.sh create mode 100644 docker/compose/register/make_image.sh create mode 100644 docker/compose/register/up.sh create mode 100644 docker/compose/up.sh create mode 100644 docker/compose/up_nginx.sh diff --git a/docker/backup/copy_keys_over.sh b/docker/backup/copy_keys_over.sh new file mode 100644 index 0000000..5f2000d --- /dev/null +++ b/docker/backup/copy_keys_over.sh @@ -0,0 +1,2 @@ +scp backup.pub overleaf@backup.zfn.uni-bremen.de:~/.ssh/authorized_keys + diff --git a/docker/backup/make_backup.sh b/docker/backup/make_backup.sh new file mode 100644 index 0000000..e9a91ab --- /dev/null +++ b/docker/backup/make_backup.sh @@ -0,0 +1,7 @@ +#!/bin/bash +cd /docker/compose/keycloakpostgres +sh backup.sh + +cd /docker/backup/ +rsync -avz --delete -e "ssh -i /docker/backup/backup" /docker overleaf@backup.zfn.uni-bremen.de:/home/overleaf/fb1sso/ + diff --git a/docker/backup/make_keys.sh b/docker/backup/make_keys.sh new file mode 100644 index 0000000..5ea2e78 --- /dev/null +++ b/docker/backup/make_keys.sh @@ -0,0 +1,2 @@ +ssh-keygen -t ed25519 -f backup + diff --git a/docker/check_docker.sh b/docker/check_docker.sh new file mode 100644 index 0000000..2c365a1 --- /dev/null +++ b/docker/check_docker.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# List of expected container names +expected_containers=("nginx" "keycloakserver" "keycloakpostgres" "register" "externals" ) + +# Email settings +recipient="overleaf@uni-bremen.de" +subject="Docker Container Alert" + +# Check containers +for container in "${expected_containers[@]}"; do + if ! docker ps --format '{{.Names}}' | grep -q "^$container$"; then + echo "Container $container is not running" | mail -s "$subject" "$recipient" + fi +done + diff --git a/docker/compose/compose.yaml b/docker/compose/compose.yaml new file mode 100644 index 0000000..cc9e8a8 --- /dev/null +++ b/docker/compose/compose.yaml @@ -0,0 +1,47 @@ +services: + keycloakpostgres: + extends: + file: ./keycloakpostgres/compose.yaml + service: keycloakpostgres + + keycloakserver: + extends: + file: ./keycloakserver/compose.yaml + service: keycloakserver + depends_on: + keycloakpostgres: + condition: service_healthy + + register: + extends: + file: ./register/compose.yaml + service: register + depends_on: + keycloakserver: + condition: service_healthy + + externals: + extends: + file: ./externals/compose.yaml + service: externals + depends_on: + keycloakserver: + condition: service_healthy + + nginx: + extends: + file: ./nginx/compose.yaml + service: nginx + depends_on: + keycloakserver: + condition: service_healthy + register: + condition: service_healthy + externals: + condition: service_healthy + + +networks: + keycloak-network: + external: true + diff --git a/docker/compose/down.sh b/docker/compose/down.sh new file mode 100644 index 0000000..c864209 --- /dev/null +++ b/docker/compose/down.sh @@ -0,0 +1,2 @@ +docker compose down + diff --git a/docker/compose/down_nginx.sh b/docker/compose/down_nginx.sh new file mode 100644 index 0000000..7af92fe --- /dev/null +++ b/docker/compose/down_nginx.sh @@ -0,0 +1,2 @@ +docker compose down nginx + diff --git a/docker/compose/exec_keycloakpostgres.sh b/docker/compose/exec_keycloakpostgres.sh new file mode 100644 index 0000000..a3aebb4 --- /dev/null +++ b/docker/compose/exec_keycloakpostgres.sh @@ -0,0 +1,2 @@ +docker exec -it keycloakpostgres bash + diff --git a/docker/compose/exec_keycloakserver.sh b/docker/compose/exec_keycloakserver.sh new file mode 100644 index 0000000..8b2945e --- /dev/null +++ b/docker/compose/exec_keycloakserver.sh @@ -0,0 +1,2 @@ +docker exec -it keycloakserver bash + diff --git a/docker/compose/exec_nginx.sh b/docker/compose/exec_nginx.sh new file mode 100644 index 0000000..ba2226f --- /dev/null +++ b/docker/compose/exec_nginx.sh @@ -0,0 +1,2 @@ +docker exec -it nginx sh + diff --git a/docker/compose/externals/Dockerfile b/docker/compose/externals/Dockerfile new file mode 100644 index 0000000..5349680 --- /dev/null +++ b/docker/compose/externals/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12.5 + +RUN apt-get update +RUN apt -y install mc +RUN apt -y install docker.io +RUN apt -y install bash +RUN pip install --upgrade pip +RUN pip install flask +RUN pip install flask_oidc +RUN pip install flask_session +RUN pip install flask_sessionstore +RUN pip install gunicorn +RUN pip install requests +RUN pip install wtforms +RUN pip install Markup +RUN pip install werkzeug +RUN pip install email_validator +RUN pip install Authlib + +EXPOSE 80 + +ENTRYPOINT ["/bin/bash", "-c", "cd data && gunicorn wsgi:app --bind 0.0.0.0:80"] + diff --git a/docker/compose/externals/compose.yaml b/docker/compose/externals/compose.yaml new file mode 100644 index 0000000..4dcb86c --- /dev/null +++ b/docker/compose/externals/compose.yaml @@ -0,0 +1,29 @@ +services: + externals: + image: "externals_image" + container_name: externals + hostname: externals + restart: always + + networks: + - keycloak-network + + volumes: + - ./data:/data + - /var/run/docker.sock:/var/run/docker.sock + + healthcheck: + test: bash -c "curl -fs --connect-timeout 10 http://localhost/externals || exit 1" + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + +# entrypoint: ["sh", "-c", "sleep infinity"] + +networks: + + keycloak-network: + external: true + + diff --git a/docker/compose/externals/data/add_user.py b/docker/compose/externals/data/add_user.py new file mode 100644 index 0000000..69ccfaf --- /dev/null +++ b/docker/compose/externals/data/add_user.py @@ -0,0 +1,74 @@ +import requests # type: ignore +import json +from requests.auth import HTTPBasicAuth # type: ignore + + +def add_keycloak_user(username) -> tuple[bool, str]: + + with open("config.json", "r") as file: + config = json.load(file) + + token_url = f"{config['keycloak_url']}/realms/master/protocol/openid-connect/token" + token_data = { + "grant_type": "password", + "username": config["admin_username"], + "password": config["admin_password"], + } + users_url = f"{config['keycloak_url']}/admin/realms/master/users" + + # Get token + try: + response = requests.post( + token_url, + data=token_data, + auth=HTTPBasicAuth(config["client_id"], config["client_secret"]), + ) + response.raise_for_status() + + except requests.exceptions.HTTPError: + return False, "SSO connection broken. No token." + + access_token = response.json()["access_token"] + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + # Check if user exists + params = {"username": username, "exact": "true"} + + try: + response = requests.get(users_url, headers=headers, params=params) + response.raise_for_status() + + # Response is a list of users matching the criteria + users = response.json() + + # If we found any users with exact username match, the user exists + if len(users) > 0: + return False, f"User {username} already exists." + + except requests.exceptions.HTTPError: + return False, "Communication with SSO server failed." + + # Make new user + new_user = { + "username": username, + "enabled": True, + "emailVerified": False, + "firstName": " ", + "lastName": " ", + "email": username, + "requiredActions": ["UPDATE_PASSWORD"], + } + + try: + # Create the user + response = requests.post(users_url, headers=headers, data=json.dumps(new_user)) + response.raise_for_status() + + except requests.exceptions.HTTPError: + return False, f"User {username} creation failed on the SSO server." + + return True, "" + diff --git a/docker/compose/externals/data/blocked_users.json b/docker/compose/externals/data/blocked_users.json new file mode 100644 index 0000000..60d6660 --- /dev/null +++ b/docker/compose/externals/data/blocked_users.json @@ -0,0 +1,6 @@ +{ + "blocked_users": [ + "" + ] +} + diff --git a/docker/compose/externals/data/config.json b/docker/compose/externals/data/config.json new file mode 100644 index 0000000..1e435a7 --- /dev/null +++ b/docker/compose/externals/data/config.json @@ -0,0 +1,15 @@ +{ + "keycloak_url": "https://sso.fb1.uni-bremen.de/sso", + "keycloak_login": "https://sso.fb1.uni-bremen.de/sso", + "issuer_url": "https://sso.fb1.uni-bremen.de/sso/realms/master", + "redirect_uri": "https://sso.fb1.uni-bremen.de/*", + "admin_username": "automation@non.no", + "admin_password": "REDACTED", + "client_id": "admin-cli", + "client_secret": "REDACTED", + "secret_key": "REDACTED", + "login_client_id": "sso_external_register", + "login_client_secret": "REDACTED", + "base_url": "https://sso.fb1.uni-bremen.de" +} + diff --git a/docker/compose/externals/data/main.py b/docker/compose/externals/data/main.py new file mode 100644 index 0000000..fae35a3 --- /dev/null +++ b/docker/compose/externals/data/main.py @@ -0,0 +1,117 @@ +import os +import json +from flask import Flask, render_template, redirect, url_for, request, session, current_app, send_from_directory, Blueprint +from authlib.integrations.flask_client import OAuth +from functools import wraps +import secrets + +from process_emails import process_emails + +app = Flask(__name__) + +# Secret key for session management +app.secret_key = os.urandom(24) + +# OAuth Configuration +oauth = OAuth(app) + +with open("config.json", "r") as file: + config_json: dict = json.load(file) + +# Keycloak Configuration +keycloak_conf = { + 'server_metadata_url': f"{config_json['issuer_url']}/.well-known/openid-configuration", + 'client_id': config_json["login_client_id"], + 'client_secret': config_json["login_client_secret"], + 'client_kwargs': { + 'scope': 'openid profile email' + } +} + +# Configure Keycloak OAuth +keycloak = oauth.register( + name='keycloak', + **keycloak_conf +) + +# Login required decorator +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user' not in session: + return redirect(url_for('externals.login')) + return f(*args, **kwargs) + return decorated_function + +# Create a blueprint for all externals routes +externals = Blueprint('externals', __name__, url_prefix='/externals') + +@externals.route('/', methods=['GET', 'POST']) +@login_required +def external_form(): + if request.method == "GET": + return render_template("post.html", username=session.get('username'), added_email="") + elif request.method == "POST": + email = request.form.get("email") + status_okay, error_string = process_emails(mail_address=email) + if status_okay: + return render_template("post.html", username=session.get('username'), added_email=f"

{email} added to the FB1 SSO users!

") + else: + return f"

Failure :-(

We couldn't register your email {email}.

Reason:

{error_string}

Back to the register form..." + +@externals.route('/login') +def login(): + # Generate nonce + nonce = secrets.token_urlsafe(16) + session['oauth_nonce'] = nonce + + redirect_uri = f"{config_json['base_url']}/externals/authorize" + return keycloak.authorize_redirect( + redirect_uri, + nonce=nonce + ) + +@externals.route("/static/", methods=["GET"]) +def serve_static_files(path): + return send_from_directory("static", path) + +@externals.route('/authorize') +def authorize(): + + # Retrieve the nonce from the session + nonce = session.pop('oauth_nonce', None) + + token = keycloak.authorize_access_token() + + # Verify the token + userinfo = keycloak.parse_id_token(token, nonce=nonce) + + # Store user information in session + session['user'] = userinfo + session['username'] = userinfo.get('preferred_username', 'User') + + return redirect(url_for('externals.external_form')) + +@externals.route('/logout') +def logout(): + # Clear the session + session.clear() + + # Redirect to Keycloak logout endpoint + logout_url = ( + f"{keycloak_conf['server_metadata_url'].replace('.well-known/openid-configuration', '')}") + \ + f"protocol/openid-connect/logout?client_id={keycloak_conf['client_id']}&" + \ + f"post_logout_redirect_uri={config_json['base_url']}" + + return redirect(logout_url) + +# Register the blueprint +app.register_blueprint(externals) + +@app.errorhandler(401) +def unauthorized(error): + return redirect(url_for('externals.login')) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=80) + diff --git a/docker/compose/externals/data/process_emails.py b/docker/compose/externals/data/process_emails.py new file mode 100644 index 0000000..8cddd2b --- /dev/null +++ b/docker/compose/externals/data/process_emails.py @@ -0,0 +1,30 @@ +from email_validator import validate_email # type: ignore +import email_validator +import json + +from add_user import add_keycloak_user + +def process_emails( + mail_address: str, + blocked_user_file: str = "blocked_users.json", +) -> tuple[bool, str]: + + with open(blocked_user_file, "r") as file: + blocked_users: dict = json.load(file) + + if (mail_address == "") or (mail_address is None): + return False, "eMail address empty or missing." + try: + emailinfo = validate_email(mail_address, check_deliverability=False) + mail_address = emailinfo.normalized + except email_validator.exceptions_types.EmailSyntaxError: + return False, f"{mail_address} -- eMail address failed validate_email (EmailSyntaxError)" + except email_validator.exceptions_types.EmailNotValidError: + return False, f"{mail_address} -- eMail address failed validate_email (EmailNotValidError)" + + for blocked_user in blocked_users["blocked_users"]: + if mail_address == blocked_user: + return False, f"{mail_address} -- eMail address is listed as blocked." + + return add_keycloak_user(mail_address) + diff --git a/docker/compose/externals/data/static/UNI_Logo.svg b/docker/compose/externals/data/static/UNI_Logo.svg new file mode 100644 index 0000000..c8afb4e --- /dev/null +++ b/docker/compose/externals/data/static/UNI_Logo.svg @@ -0,0 +1,50 @@ + + + + diff --git a/docker/compose/externals/data/templates/post.html b/docker/compose/externals/data/templates/post.html new file mode 100644 index 0000000..ee17ab8 --- /dev/null +++ b/docker/compose/externals/data/templates/post.html @@ -0,0 +1,100 @@ + + + + + + + Register your FB1 guest's account + + + +

+ University of Bremen Logo +
+ +
+ Welcome, {{ username }} | Logout +
+ +

+ + {{ added_email|safe }} +

Register your FB1 guest's account

+ + + Use this form and afterwards the guest needs to use the password via the "Forgot Password?" option during the login process.

+ +

+
+ + +
+

+ +

+ + + + diff --git a/docker/compose/externals/data/wsgi.py b/docker/compose/externals/data/wsgi.py new file mode 100644 index 0000000..9e7cbee --- /dev/null +++ b/docker/compose/externals/data/wsgi.py @@ -0,0 +1,5 @@ +from main import app + +if __name__ == "__main__": + app.run(debug=True) + diff --git a/docker/compose/externals/down.sh b/docker/compose/externals/down.sh new file mode 100644 index 0000000..c864209 --- /dev/null +++ b/docker/compose/externals/down.sh @@ -0,0 +1,2 @@ +docker compose down + diff --git a/docker/compose/externals/logs.sh b/docker/compose/externals/logs.sh new file mode 100644 index 0000000..5fd46e9 --- /dev/null +++ b/docker/compose/externals/logs.sh @@ -0,0 +1,2 @@ +docker compose logs -f + diff --git a/docker/compose/externals/make_image.sh b/docker/compose/externals/make_image.sh new file mode 100644 index 0000000..0280516 --- /dev/null +++ b/docker/compose/externals/make_image.sh @@ -0,0 +1,2 @@ +docker build --network host -t externals_image . + diff --git a/docker/compose/externals/up.sh b/docker/compose/externals/up.sh new file mode 100644 index 0000000..6da72b7 --- /dev/null +++ b/docker/compose/externals/up.sh @@ -0,0 +1,3 @@ +docker compose down +docker compose up -d + diff --git a/docker/compose/keycloakpostgres/backup.sh b/docker/compose/keycloakpostgres/backup.sh new file mode 100644 index 0000000..ee2b8a4 --- /dev/null +++ b/docker/compose/keycloakpostgres/backup.sh @@ -0,0 +1,2 @@ +docker exec keycloakpostgres bash -c "pg_dump -U keycloakuser -d keycloak -F c -f /backup/backup.sql" + diff --git a/docker/compose/keycloakpostgres/compose.yaml b/docker/compose/keycloakpostgres/compose.yaml new file mode 100644 index 0000000..279e614 --- /dev/null +++ b/docker/compose/keycloakpostgres/compose.yaml @@ -0,0 +1,26 @@ +services: + keycloakpostgres: + image: postgres:16 + container_name: keycloakpostgres + hostname: keycloakpostgres + volumes: + - ./postgres_data:/var/lib/postgresql/data + - ./backup:/backup + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + networks: + - keycloak-network + +networks: + keycloak-network: + external: true + diff --git a/docker/compose/keycloakserver/compose.yaml b/docker/compose/keycloakserver/compose.yaml new file mode 100644 index 0000000..8d06fa5 --- /dev/null +++ b/docker/compose/keycloakserver/compose.yaml @@ -0,0 +1,51 @@ +services: + keycloakserver: + image: quay.io/keycloak/keycloak:26.1 + container_name: keycloakserver + hostname: keycloakserver + command: start + environment: + KC_PROXY_ADDRESS_FORWARDING: true + KC_HOSTNAME_STRICT: false + KC_HOSTNAME: ${KEYCLOAK_HOSTNAME} + KC_PROXY: edge + KC_HTTP_ENABLED: true + KC_HEALTH_ENABLED: true + KC_HTTP_RELATIVE_PATH: /sso + KC_PROXY_HEADERS: xforwarded + PROXY_ADDRESS_FORWARDING: true + + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} + + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://keycloakpostgres/${POSTGRES_DB} + KC_DB_USERNAME: ${POSTGRES_USER} + KC_DB_PASSWORD: ${POSTGRES_PASSWORD} + +# KC_LOG_LEVEL: INFO +# KC_LOG_LEVEL: DEBUG + + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000;echo -e 'GET /sso/health/ready HTTP/1.1\r\nhost: http://localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + + volumes: + - ./export_data:/export_data + - ./custom:/opt/keycloak/themes/custom + + ports: + - 8080:8080 + - 9000:9000 + restart: always + + networks: + - keycloak-network + +networks: + keycloak-network: + external: true + diff --git a/docker/compose/keycloakserver/custom/login/buttons.ftl b/docker/compose/keycloakserver/custom/login/buttons.ftl new file mode 100644 index 0000000..90e499c --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/buttons.ftl @@ -0,0 +1,21 @@ +<#macro actionGroup> +
+
+ <#nested> +
+
+ + +<#macro button label id="" name="" class=["kcButtonPrimaryClass"]> + + + +<#macro buttonLink href label id="" class=["kcButtonSecondaryClass"]> + ${kcSanitize(msg(label))?no_esc} + + +<#macro loginButton> + <@buttons.actionGroup> + <@buttons.button id="kc-login" name="login" label="doLogIn" class=["kcButtonPrimaryClass", "kcButtonBlockClass"] /> + + \ No newline at end of file diff --git a/docker/compose/keycloakserver/custom/login/code.ftl b/docker/compose/keycloakserver/custom/login/code.ftl new file mode 100644 index 0000000..61f855e --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/code.ftl @@ -0,0 +1,20 @@ +<#import "template.ftl" as layout> +<#import "field.ftl" as field> +<@layout.registrationLayout; section> + <#if section = "header"> + <#if code.success> + ${msg("codeSuccessTitle")} + <#else> + ${kcSanitize(msg("codeErrorTitle", code.error))} + + <#elseif section = "form"> +
+ <#if code.success> +

${msg("copyCodeInstruction")}

+ <@field.input name="code" label="" value=code.code /> + <#else> +

${kcSanitize(code.error)}

+ +
+ + diff --git a/docker/compose/keycloakserver/custom/login/delete-account-confirm.ftl b/docker/compose/keycloakserver/custom/login/delete-account-confirm.ftl new file mode 100644 index 0000000..c755fad --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/delete-account-confirm.ftl @@ -0,0 +1,40 @@ +<#import "template.ftl" as layout> +<#import "buttons.ftl" as buttons> + +<@layout.registrationLayout; section> + + + <#if section = "header"> + ${msg("deleteAccountConfirm")} + + <#elseif section = "form"> + +
+ +
+
+ +
+ + ${msg("irreversibleAction")} + +
+ +

${msg("deletingImplies")}

+ + + + + <@buttons.actionGroup> + <@buttons.button label="doConfirmDelete" class=["kcButtonPrimaryClass"]/> + <#if triggered_from_aia> + <@buttons.button name="cancel-aia" label="doCancel" class=["kcButtonSecondaryClass"]/> + + +
+ + + \ No newline at end of file diff --git a/docker/compose/keycloakserver/custom/login/delete-credential.ftl b/docker/compose/keycloakserver/custom/login/delete-credential.ftl new file mode 100644 index 0000000..fe6a815 --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/delete-credential.ftl @@ -0,0 +1,21 @@ +<#import "template.ftl" as layout> +<#import "buttons.ftl" as buttons> + +<@layout.registrationLayout displayMessage=false; section> + + + <#if section = "header"> + ${msg("deleteCredentialTitle", credentialLabel)} + <#elseif section = "form"> +
+ ${msg("deleteCredentialMessage", credentialLabel)} +
+ +
+ <@buttons.actionGroup> + <@buttons.button name="accept" id="kc-accept" label="doConfirmDelete" class=["kcButtonPrimaryClass"]/> + <@buttons.button name="cancel-aia" id="kc-decline" label="doDecline" class=["kcButtonSecondaryClass"]/> + +
+ diff --git a/docker/compose/keycloakserver/custom/login/field.ftl b/docker/compose/keycloakserver/custom/login/field.ftl new file mode 100644 index 0000000..03b8925 --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/field.ftl @@ -0,0 +1,103 @@ +<#macro group name label error="" required=false> + +
+
+ +
+ + <#nested> + +
+ <#if error?has_content> +
+
+
+ + ${error} + +
+
+
+ +
+
+ + + +<#macro errorIcon error=""> + <#if error?has_content> + + + + + + + + +<#macro input name label value="" required=false autocomplete="off" fieldName=name error=kcSanitize(messagesPerField.get(fieldName))?no_esc autofocus=false> + <@group name=name label=label error=error required=required> + + autofocus + aria-invalid="<#if error?has_content>true"/> + <@errorIcon error=error/> + + + + +<#macro password name label value="" required=false forgotPassword=false fieldName=name error=kcSanitize(messagesPerField.get(fieldName))?no_esc autocomplete="off" autofocus=false> + <@group name=name label=label error=error required=required> +
+
+ + autofocus + aria-invalid="<#if error?has_content>true"/> + <@errorIcon error=error/> + +
+
+ +
+
+ <#if forgotPassword> +
+ +
+ + + + +<#macro checkbox name label value=false required=false> +
+ +
+ \ No newline at end of file diff --git a/docker/compose/keycloakserver/custom/login/login-config-totp.ftl b/docker/compose/keycloakserver/custom/login/login-config-totp.ftl new file mode 100644 index 0000000..47a57fb --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/login-config-totp.ftl @@ -0,0 +1,123 @@ +<#import "template.ftl" as layout> +<#import "field.ftl" as field> +<#import "password-commons.ftl" as passwordCommons> +<@layout.registrationLayout displayRequiredFields=false displayMessage=!messagesPerField.existsError('totp','userLabel'); section> + + + <#if section = "header"> + ${msg("loginTotpTitle")} + <#elseif section = "form"> +
    +
  1. +

    ${msg("loginTotpStep1")}

    + +
      + <#list totp.supportedApplications as app> +
    • ${msg(app)}
    • + +
    +
  2. + + <#if mode?? && mode = "manual"> +
  3. +

    ${msg("loginTotpManualStep2")}

    +

    ${totp.totpSecretEncoded}

    +

    ${msg("loginTotpScanBarcode")}

    +
  4. +
  5. +

    ${msg("loginTotpManualStep3")}

    +

    +

      +
    • ${msg("loginTotpType")}: ${msg("loginTotp." + totp.policy.type)}
    • +
    • ${msg("loginTotpAlgorithm")}: ${totp.policy.getAlgorithmKey()}
    • +
    • ${msg("loginTotpDigits")}: ${totp.policy.digits}
    • + <#if totp.policy.type = "totp"> +
    • ${msg("loginTotpInterval")}: ${totp.policy.period}
    • + <#elseif totp.policy.type = "hotp"> +
    • ${msg("loginTotpCounter")}: ${totp.policy.initialCounter}
    • + +
    +

    +
  6. + <#else> +
  7. +

    ${msg("loginTotpStep2")}

    + Figure: Barcode
    +

    ${msg("loginTotpUnableToScan")}

    +
  8. + +
  9. +

    ${msg("loginTotpStep3")}

    +

    ${msg("loginTotpStep3DeviceName")}

    +
  10. +
+ +
+
+
+ +
+
+ + + <@field.errorIcon error=kcSanitize(messagesPerField.get('totp'))?no_esc/> +
+ <#if messagesPerField.existsError('totp')> + + ${kcSanitize(messagesPerField.get('totp'))?no_esc} + + + + <#if mode??> +
+
+
+ +
+ +
+ + + <@field.errorIcon error=kcSanitize(messagesPerField.get('userLabel'))?no_esc/> +
+ <#if messagesPerField.existsError('userLabel')> + + ${kcSanitize(messagesPerField.get('userLabel'))?no_esc} + + +
+ +
+ <@passwordCommons.logoutOtherSessions/> +
+ +
+
+ <#if isAppInitiatedAction??> + + + <#else> + + +
+
+
+ + diff --git a/docker/compose/keycloakserver/custom/login/login-oauth-grant.ftl b/docker/compose/keycloakserver/custom/login/login-oauth-grant.ftl new file mode 100644 index 0000000..5ffc8a2 --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/login-oauth-grant.ftl @@ -0,0 +1,59 @@ +<#import "template.ftl" as layout> +<#import "buttons.ftl" as buttons> +<@layout.registrationLayout bodyClass="oauth"; section> + <#if section = "header"> + <#if client.attributes.logoUri??> + + +

+ <#if client.name?has_content> + ${msg("oauthGrantTitle",advancedMsg(client.name))} + <#else> + ${msg("oauthGrantTitle",client.clientId)} + +

+ <#elseif section = "form"> +
+

${msg("oauthGrantRequest")}

+ + <#if client.attributes.policyUri?? || client.attributes.tosUri??> +

+ <#if client.name?has_content> + ${msg("oauthGrantInformation",advancedMsg(client.name))} + <#else> + ${msg("oauthGrantInformation",client.clientId)} + + <#if client.attributes.tosUri??> + ${msg("oauthGrantReview")} + ${msg("oauthGrantTos")} + + <#if client.attributes.policyUri??> + ${msg("oauthGrantReview")} + ${msg("oauthGrantPolicy")} + +

+ + +
+ + <@buttons.actionGroup> + <@buttons.button id="kc-login" name="accept" label="doYes"/> + <@buttons.button id="kc-cancel" name="cancel" label="doNo" class=["kcButtonSecondaryClass"]/> + +
+
+ + diff --git a/docker/compose/keycloakserver/custom/login/login-otp.ftl b/docker/compose/keycloakserver/custom/login/login-otp.ftl new file mode 100644 index 0000000..1b213ad --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/login-otp.ftl @@ -0,0 +1,45 @@ +<#import "template.ftl" as layout> +<#import "field.ftl" as field> +<#import "buttons.ftl" as buttons> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('totp'); section> + + + <#if section="header"> + ${msg("doLogIn")} + <#elseif section="form"> +
+ + <#if otpLogin.userOtpCredentials?size gt 1> +
+
+ <#list otpLogin.userOtpCredentials as otpCredential> +
+ + + + + ${otpCredential.userLabel} + +
+ +
+
+ + + <@field.input name="otp" label=msg("loginOtpOneTime") autocomplete="one-time-code" fieldName="totp" autofocus=true /> + + <@buttons.loginButton /> +
+ + + \ No newline at end of file diff --git a/docker/compose/keycloakserver/custom/login/login-password.ftl b/docker/compose/keycloakserver/custom/login/login-password.ftl new file mode 100644 index 0000000..19afdc0 --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/login-password.ftl @@ -0,0 +1,19 @@ +<#import "template.ftl" as layout> +<#import "field.ftl" as field> +<#import "buttons.ftl" as buttons> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('password'); section> + + <#if section = "header"> + ${msg("doLogIn")} + <#elseif section = "form"> +
+
+
+ <@field.password name="password" label=msg("password") forgotPassword=realm.resetPasswordAllowed autofocus=true autocomplete="current-password" /> + <@buttons.loginButton /> +
+
+
+ + + diff --git a/docker/compose/keycloakserver/custom/login/login-recovery-authn-code-config.ftl b/docker/compose/keycloakserver/custom/login/login-recovery-authn-code-config.ftl new file mode 100644 index 0000000..a892c4d --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/login-recovery-authn-code-config.ftl @@ -0,0 +1,180 @@ +<#import "template.ftl" as layout> +<#import "password-commons.ftl" as passwordCommons> +<@layout.registrationLayout; section> + +<#if section = "header"> + ${msg("recovery-code-config-header")} +<#elseif section = "form"> + +
+
+ +
+

+ Warning alert: + ${msg("recovery-code-config-warning-title")} +

+
+

${msg("recovery-code-config-warning-message")}

+
+
+ +
+
+
+
    + <#list recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesList as code> +
  1. ${code[0..3]}-${code[4..7]}-${code[8..]}
  2. + +
+
+
+
+ + +
+ + + +
+ + +
+ + +
+ +
+ + + + <@passwordCommons.logoutOtherSessions/> + + <#if isAppInitiatedAction??> + + + <#else> + + +
+ + + + diff --git a/docker/compose/keycloakserver/custom/login/login-recovery-authn-code-input.ftl b/docker/compose/keycloakserver/custom/login/login-recovery-authn-code-input.ftl new file mode 100644 index 0000000..95275d5 --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/login-recovery-authn-code-input.ftl @@ -0,0 +1,15 @@ +<#import "template.ftl" as layout> +<#import "field.ftl" as field> +<#import "buttons.ftl" as buttons> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('recoveryCodeInput'); section> + + <#if section = "header"> + ${msg("auth-recovery-code-header")} + <#elseif section = "form"> +
+ <@field.input name="recoveryCodeInput" label=msg("auth-recovery-code-prompt", recoveryAuthnCodesInputBean.codeNumber?c) autofocus=true /> + + <@buttons.loginButton /> +
+ + \ No newline at end of file diff --git a/docker/compose/keycloakserver/custom/login/login-reset-password.ftl b/docker/compose/keycloakserver/custom/login/login-reset-password.ftl new file mode 100644 index 0000000..f67d935 --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/login-reset-password.ftl @@ -0,0 +1,27 @@ +<#import "template.ftl" as layout> +<#import "field.ftl" as field> +<#import "buttons.ftl" as buttons> +<@layout.registrationLayout displayInfo=true displayMessage=!messagesPerField.existsError('username'); section> + <#if section = "header"> + ${msg("emailForgotTitle")} + <#elseif section = "form"> +
+ <#assign label> + <#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")} + + <@field.input name="username" label=label value=auth.attemptedUsername!'' autofocus=true /> + + <@buttons.actionGroup> + <@buttons.button id="kc-form-buttons" label="doSubmit" class=["kcButtonPrimaryClass", "kcButtonBlockClass"]/> + <@buttons.buttonLink href=url.loginUrl label="backToLogin" class=["kcButtonSecondaryClass", "kcButtonBlockClass"]/> + + +
+ <#elseif section = "info" > + <#if realm.duplicateEmailsAllowed> + ${msg("emailInstructionUsername")} + <#else> + ${msg("emailInstruction")} + + + \ No newline at end of file diff --git a/docker/compose/keycloakserver/custom/login/login-update-password.ftl b/docker/compose/keycloakserver/custom/login/login-update-password.ftl new file mode 100644 index 0000000..5d7b1f5 --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/login-update-password.ftl @@ -0,0 +1,32 @@ +<#import "template.ftl" as layout> +<#import "password-commons.ftl" as passwordCommons> +<#import "field.ftl" as field> +<#import "buttons.ftl" as buttons> +<#import "password-validation.ftl" as validator> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('password','password-confirm'); section> + + <#if section = "header"> + ${msg("updatePasswordTitle")} + <#elseif section = "form"> +
+ <@field.password name="password-new" label=msg("passwordNew") fieldName="password" autocomplete="new-password" autofocus=true /> + <@field.password name="password-confirm" label=msg("passwordConfirm") autocomplete="new-password" /> + +
+ <@passwordCommons.logoutOtherSessions/> +
+ + <@buttons.actionGroup> + <#if isAppInitiatedAction??> + <@buttons.button label="doSubmit" class=["kcButtonPrimaryClass"]/> + <@buttons.button label="doCancel" name="cancel-aia" class=["kcButtonSecondaryClass"]/> + <#else> + <@buttons.button label="doSubmit" class=["kcButtonPrimaryClass", "kcButtonBlockClass"]/> + + +
+ + <@validator.templates/> + <@validator.script field="password-new"/> + + diff --git a/docker/compose/keycloakserver/custom/login/login-username.ftl b/docker/compose/keycloakserver/custom/login/login-username.ftl new file mode 100644 index 0000000..e26b8f9 --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/login-username.ftl @@ -0,0 +1,55 @@ +<#import "template.ftl" as layout> +<#import "field.ftl" as field> +<#import "buttons.ftl" as buttons> +<#import "social-providers.ftl" as identityProviders> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username') displayInfo=(realm.password && realm.registrationAllowed && !registrationDisabled??); section> + + + <#if section = "header"> + ${msg("loginAccountTitle")} + <#elseif section = "form"> +
+
+ <#if realm.password> +
+ <#if !usernameHidden??> +
+ <#assign label> + <#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")} + + <@field.input name="username" label=label value=login.username!'' autofocus=true autocomplete="username" /> + + <#if messagesPerField.existsError('username')> + + ${kcSanitize(messagesPerField.get('username'))?no_esc} + + +
+ + +
+ <#if realm.rememberMe && !usernameHidden??> + <@field.checkbox name="rememberMe" label=msg("rememberMe") value=login.rememberMe?? /> + +
+ + <@buttons.loginButton /> +
+ +
+
+ + <#elseif section = "info" > + <#if realm.password && realm.registrationAllowed && !registrationDisabled??> +
+ ${msg("noAccount")} ${msg("doRegister")} +
+ + <#elseif section = "socialProviders" > + <#if realm.password && social.providers?? && social.providers?has_content> + <@identityProviders.show social=social /> + + + + diff --git a/docker/compose/keycloakserver/custom/login/login.ftl b/docker/compose/keycloakserver/custom/login/login.ftl new file mode 100644 index 0000000..dfdf0c3 --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/login.ftl @@ -0,0 +1,51 @@ +<#import "template.ftl" as layout> +<#import "field.ftl" as field> +<#import "buttons.ftl" as buttons> +<#import "social-providers.ftl" as identityProviders> +<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled??; section> + + + <#if section = "header"> + ${msg("loginAccountTitle")} + <#elseif section = "form"> + + + <@identityProviders.show social=social/> + +
+ + +
+ +
+
+
+ <#if !usernameHidden??> + <#assign label> + <#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")} + + <@field.input name="username" label=label error=kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc autofocus=true autocomplete="username" value=login.username!'' /> + <@field.password name="password" label=msg("password") error="" forgotPassword=realm.resetPasswordAllowed autofocus=usernameHidden?? autocomplete="current-password" /> + <#else> + <@field.password name="password" label=msg("password") forgotPassword=realm.resetPasswordAllowed autofocus=usernameHidden?? autocomplete="current-password" /> + + +
+ <#if realm.rememberMe && !usernameHidden??> + <@field.checkbox name="rememberMe" label=msg("rememberMe") value=login.rememberMe?? /> + +
+ + value="${auth.selectedCredential}"/> + <@buttons.loginButton /> +
+
+
+ + + diff --git a/docker/compose/keycloakserver/custom/login/password-commons.ftl b/docker/compose/keycloakserver/custom/login/password-commons.ftl new file mode 100644 index 0000000..35ed883 --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/password-commons.ftl @@ -0,0 +1,12 @@ +<#macro logoutOtherSessions> +
+
+
+ + +
+
+
+ diff --git a/docker/compose/keycloakserver/custom/login/password-validation.ftl b/docker/compose/keycloakserver/custom/login/password-validation.ftl new file mode 100644 index 0000000..d2a2174 --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/password-validation.ftl @@ -0,0 +1,51 @@ +<#macro templates> + + + + +<#macro script field=""> + + \ No newline at end of file diff --git a/docker/compose/keycloakserver/custom/login/register-commons.ftl b/docker/compose/keycloakserver/custom/login/register-commons.ftl new file mode 100644 index 0000000..7007797 --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/register-commons.ftl @@ -0,0 +1,27 @@ +<#macro termsAcceptance> + <#if termsAcceptanceRequired??> +
+
+ ${msg("termsTitle")} +
+ ${kcSanitize(msg("termsText"))?no_esc} +
+
+
+
+
+ + +
+ <#if messagesPerField.existsError('termsAccepted')> +
+ + ${kcSanitize(messagesPerField.get('termsAccepted'))?no_esc} + +
+ +
+ + diff --git a/docker/compose/keycloakserver/custom/login/register.ftl b/docker/compose/keycloakserver/custom/login/register.ftl new file mode 100644 index 0000000..a140265 --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/register.ftl @@ -0,0 +1,68 @@ +<#import "template.ftl" as layout> +<#import "field.ftl" as field> +<#import "user-profile-commons.ftl" as userProfileCommons> +<#import "register-commons.ftl" as registerCommons> +<#import "password-validation.ftl" as validator> +<@layout.registrationLayout displayMessage=messagesPerField.exists('global') displayRequiredFields=true; section> + + + <#if section = "header"> + <#if messageHeader??> + ${kcSanitize(msg("${messageHeader}"))?no_esc} + <#else> + ${msg("registerTitle")} + + <#elseif section = "form"> +
+ <@userProfileCommons.userProfileFormFields; callback, attribute> + <#if callback = "afterField"> + <#-- render password fields just under the username or email (if used as username) --> + <#if passwordRequired?? && (attribute.name == 'username' || (attribute.name == 'email' && realm.registrationEmailAsUsername))> + <@field.password name="password" required=true label=msg("password") autocomplete="new-password" /> + <@field.password name="password-confirm" required=true label=msg("passwordConfirm") autocomplete="new-password" /> + + + + + <@registerCommons.termsAcceptance/> + + <#if recaptchaRequired?? && (recaptchaVisible!false)> +
+
+
+
+
+ + + <#if recaptchaRequired?? && !(recaptchaVisible!false)> + +
+ +
+ <#else> +
+ +
+ + + + +
+ + <@validator.templates/> + <@validator.script field="password"/> + + diff --git a/docker/compose/keycloakserver/custom/login/resources/css/styles.css b/docker/compose/keycloakserver/custom/login/resources/css/styles.css new file mode 100644 index 0000000..0c0011f --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/resources/css/styles.css @@ -0,0 +1,211 @@ +:root { + --keycloak-logo-url: url('../img/UNI_Logo.svg'); + --keycloak-logo-height: 150px; + --keycloak-logo-width: 300px; +} + +.pf-v5-c-title.pf-m-3xl { + color: #00326d; +} + +.pf-v5-c-form__label { + color: #00326d; +} + +.pf-v5-c-login__main { + background-color: #d5e3f3; +} + +.pf-v5-c-button.pf-m-primary.pf-m-block { + background-color: #00326d; + font-weight: bold; + color: #ffffff; +} + +.pf-v5-c-form-control { + background-color: #ffffff; + color: #000000; +} + +.pf-v5-c-form-control select { + background-color: #ffffff; + color: #000000; +} + +.pf-v5-c-input-group__item.pf-m-fill { + background-color: #ffffff; + color: #000000; +} + + +.pf-v5-c-helper-text__item-text{ + color: #00326d; +} + +.pf-v5-c-helper-text__item-text a { + color: #00326d; +} + +.pf-v5-c-helper-text__item-text a:hover { + color: #000000; +} + +.pf-v5-c-helper-text__item-text a:visited { + color: #00326d; +} + +.pf-v5-c-check__label { + color: #00326d; +} + +.pf-v5-c-login__main-footer-band-item { + color: #00326d; + font-weight: bold; +} + +.pf-v5-c-login__container { + grid-template-columns: 34rem; + grid-template-areas: "header" + "main" +} + +.pf-v5-c-login__main-footer-links { + width: 100%; +} + +.marx { + background-color: #00326d; + font-weight: bold; + color: #f39ca9; + padding: 0.375rem 1rem; + text-align: center; + vertical-align: middle; + text-decoration: none; + height: 2.5rem; + line-height: 1.5rem; + width: 100%; +} + +.marx a { + background-color: #00326d; + font-weight: bold; + color: #ffffff; + text-align: center; + vertical-align: middle; +} + +.marx a:hover { + background-color: #00326d; + font-weight: bold; + color: #ffffff; + text-decoration: none; + text-align: center; + vertical-align: middle; +} + +.marx a:visited { + background-color: #00326d; + font-weight: bold; + color: #ffffff; + text-align: center; + vertical-align: middle; +} + +.pf-v5-c-input-group__item { + background-color: #00326d; +} + +.login-select-toggle { + background-color: #00326d; +} + +.login-pf body { + background: var(--keycloak-bg-logo-url) no-repeat center center fixed; + background-size: cover; + height: 100%; + background-color: #fff; +} + +div.kc-logo-text { + background-image: var(--keycloak-logo-url); + height: var(--keycloak-logo-height); + width: var(--keycloak-logo-width); + background-repeat: no-repeat; + background-size: contain; + margin: 0 auto; +} + +div.kc-logo-text span { + display: none; +} + +.kc-login-tooltip { + position: relative; + display: inline-block; +} + +.kc-login-tooltip .kc-tooltip-text{ + top:-3px; + left:160%; + background-color: black; + visibility: hidden; + color: #fff; + + min-width:130px; + text-align: center; + border-radius: 2px; + box-shadow:0 1px 8px rgba(0,0,0,0.6); + padding: 5px; + + position: absolute; + opacity:0; + transition:opacity 0.5s; +} + +/* Show tooltip */ +.kc-login-tooltip:hover .kc-tooltip-text { + visibility: visible; + opacity:0.7; +} + +/* Arrow for tooltip */ +.kc-login-tooltip .kc-tooltip-text::after { + content: " "; + position: absolute; + top: 15px; + right: 100%; + margin-top: -5px; + border-width: 5px; + border-style: solid; + border-color: transparent black transparent transparent; +} + +#kc-recovery-codes-list { + columns: 2; +} + +#certificate_subjectDN { + overflow-wrap: break-word +} + +#kc-header-wrapper { + font-size: 29px; + text-transform: uppercase; + letter-spacing: 3px; + line-height: 1.2em; + white-space: normal; + color: var(--pf-v5-global--Color--light-100) !important; + text-align: center; + margin: 0 auto; +} + +hr { + margin-top: var(--pf-v5-global--spacer--sm); + margin-bottom: var(--pf-v5-global--spacer--md); +} + +@media (min-width: 768px) { + div.pf-v5-c-login__main-header { + grid-template-columns: 70% 30%; + } +} \ No newline at end of file diff --git a/docker/compose/keycloakserver/custom/login/resources/img/UNI_Logo.svg b/docker/compose/keycloakserver/custom/login/resources/img/UNI_Logo.svg new file mode 100644 index 0000000..c8afb4e --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/resources/img/UNI_Logo.svg @@ -0,0 +1,50 @@ + + + + diff --git a/docker/compose/keycloakserver/custom/login/resources/js/password-policy.js b/docker/compose/keycloakserver/custom/login/resources/js/password-policy.js new file mode 100644 index 0000000..25542e3 --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/resources/js/password-policy.js @@ -0,0 +1,53 @@ +const policies = { + length: (policy, value) => { + if (value.length < policy.value) { + return templateError(policy); + } + }, + maxLength: (policy, value) => { + if (value.length > policy.value) { + return templateError(policy); + } + }, + upperCase: (policy, value) => { + if ( + value.split("").filter((char) => char === char.toUpperCase() && char !== char.toLowerCase()).length < + policy.value + ) { + return templateError(policy); + } + }, + lowerCase: (policy, value) => { + if ( + value.split("").filter((char) => char === char.toLowerCase() && char !== char.toUpperCase()).length < + policy.value + ) { + return templateError(policy); + } + }, + digits: (policy, value) => { + const digits = value.split("").filter((char) => char.match(/\d/)); + if (digits.length < policy.value) { + return templateError(policy); + } + }, + specialChars: (policy, value) => { + let specialChars = value.split("").filter((char) => char.match(/\W/)); + if (specialChars.length < policy.value) { + return templateError(policy); + } + }, +}; + +const templateError = (policy) => policy.error.replace("{0}", policy.value); + +export function validatePassword(password, activePolicies) { + const errors = []; + for (const p of activePolicies) { + const validationError = policies[p.name](p.policy, password); + if (validationError) { + errors.push(validationError); + } + } + return errors; +} diff --git a/docker/compose/keycloakserver/custom/login/resources/js/userProfile.js b/docker/compose/keycloakserver/custom/login/resources/js/userProfile.js new file mode 100644 index 0000000..bfde5ae --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/resources/js/userProfile.js @@ -0,0 +1,72 @@ +// @ts-check +/** + * @typedef {Object} AnnotationDescriptor + * @property {string} name - The name of the field to register (e.g. `numberFormat`). + * @property {(element: HTMLElement) => (() => void) | void} onAdd - The function to call when a new element is added to the DOM. + */ + +const observer = new MutationObserver(onMutate); +observer.observe(document.body, { childList: true, subtree: true }); + +/** @type {AnnotationDescriptor[]} */ +const descriptors = []; + +/** @type {WeakMap void>} */ +const cleanupFunctions = new WeakMap(); + +/** + * @param {AnnotationDescriptor} descriptor + */ +export function registerElementAnnotatedBy(descriptor) { + descriptors.push(descriptor); + + document.querySelectorAll(`[data-${descriptor.name}]`).forEach((element) => { + if (element instanceof HTMLElement) { + handleNewElement(element, descriptor); + } + }); +} + +/** + * @type {MutationCallback} + */ +function onMutate(mutations) { + const removedNodes = mutations.flatMap((mutation) => Array.from(mutation.removedNodes)); + + for (const node of removedNodes) { + if (!(node instanceof HTMLElement)) { + continue; + } + + const handleRemovedElement = cleanupFunctions.get(node); + + if (handleRemovedElement) { + handleRemovedElement(); + } + + cleanupFunctions.delete(node); + } + + const addedNodes = mutations.flatMap((mutation) => Array.from(mutation.addedNodes)); + + for (const descriptor of descriptors) { + for (const node of addedNodes) { + const input = node.querySelector('input'); + if (input.hasAttribute(`data-${descriptor.name}`)) { + handleNewElement(input, descriptor); + } + } + } +} + +/** + * @param {HTMLElement} element + * @param {AnnotationDescriptor} descriptor + */ +function handleNewElement(element, descriptor) { + const cleanup = descriptor.onAdd(element); + + if (cleanup) { + cleanupFunctions.set(element, cleanup); + } +} diff --git a/docker/compose/keycloakserver/custom/login/select-authenticator.ftl b/docker/compose/keycloakserver/custom/login/select-authenticator.ftl new file mode 100644 index 0000000..ced7278 --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/select-authenticator.ftl @@ -0,0 +1,41 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayInfo=false; section> + + + <#if section = "header" || section = "show-username"> + <#if section = "header"> + ${msg("loginChooseAuthenticator")} + + <#elseif section = "form"> + + + + + + diff --git a/docker/compose/keycloakserver/custom/login/social-providers.ftl b/docker/compose/keycloakserver/custom/login/social-providers.ftl new file mode 100644 index 0000000..17ca0ca --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/social-providers.ftl @@ -0,0 +1,39 @@ +<#macro show social> + + \ No newline at end of file diff --git a/docker/compose/keycloakserver/custom/login/template.ftl b/docker/compose/keycloakserver/custom/login/template.ftl new file mode 100644 index 0000000..fa9031d --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/template.ftl @@ -0,0 +1,236 @@ +<#import "field.ftl" as field> +<#import "footer.ftl" as loginFooter> +<#macro username> + <#assign label> + <#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")} + + <@field.group name="username" label=label> +
+
+ + + +
+
+ +
+ + + +<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false> + + dir="${(locale.rtl)?then('rtl','ltr')}"> + + + + + + + + + <#if properties.meta?has_content> + <#list properties.meta?split(' ') as meta> + + + + ${msg("loginTitle",(realm.displayName!''))} + + <#if properties.stylesCommon?has_content> + <#list properties.stylesCommon?split(' ') as style> + + + + <#if properties.styles?has_content> + <#list properties.styles?split(' ') as style> + + + + + <#if darkMode> + + + <#if properties.scripts?has_content> + <#list properties.scripts?split(' ') as script> + + + + <#if scripts??> + <#list scripts as script> + + + + + + <#if authenticationSession??> + + + + + + +
+
+ +
+
+

<#nested "header">

+ <#if realm.internationalizationEnabled && locale.supported?size gt 1> +
+
+ + + + + + +
+
+ +
+
+ <#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())> + <#if displayRequiredFields> +
+
+ + * ${msg("requiredFields")} + +
+
+ + <#else> + <#if displayRequiredFields> +
+
+ + * ${msg("requiredFields")} + +
+
+ <#nested "show-username"> + <@username /> +
+
+ <#else> +
+ <#nested "show-username"> + <@username /> +
+ + + + <#-- App-initiated actions should not see warning messages about the need to complete the action --> + <#-- during login. --> + <#if displayMessage && message?has_content && (message.type != 'warning' || !isAppInitiatedAction??)> +
+
+ <#if message.type = 'success'> + <#if message.type = 'warning'> + <#if message.type = 'error'> + <#if message.type = 'info'> +
+ +
+ + + <#nested "form"> + + <#if auth?has_content && auth.showTryAnotherWayLink()> +
+ + + ${kcSanitize(msg("doTryAnotherWay"))?no_esc} + +
+ + + <#if displayInfo> +
+
+ <#nested "info"> +
+
+ +
+ +
+ + <@loginFooter.content/> +
+
+ + + diff --git a/docker/compose/keycloakserver/custom/login/terms.ftl b/docker/compose/keycloakserver/custom/login/terms.ftl new file mode 100644 index 0000000..d92daaf --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/terms.ftl @@ -0,0 +1,21 @@ +<#import "template.ftl" as layout> +<#import "buttons.ftl" as buttons> + +<@layout.registrationLayout displayMessage=false; section> + + + <#if section = "header"> + ${msg("termsTitle")} + <#elseif section = "form"> +
+ ${kcSanitize(msg("termsText"))?no_esc} +
+
+ <@buttons.actionGroup> + <@buttons.button name="accept" id="kc-accept" label="doAccept" class=["kcButtonPrimaryClass"]/> + <@buttons.button name="cancel" id="kc-decline" label="doDecline" class=["kcButtonSecondaryClass"]/> + +
+
+ + diff --git a/docker/compose/keycloakserver/custom/login/theme.properties b/docker/compose/keycloakserver/custom/login/theme.properties new file mode 100644 index 0000000..08b4c7c --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/theme.properties @@ -0,0 +1,110 @@ +parent=base +import=common/keycloak + +styles=css/styles.css +stylesCommon=vendor/patternfly-v5/patternfly.min.css vendor/patternfly-v5/patternfly-addons.css + +darkMode=false + +kcFormGroupClass=pf-v5-c-form__group +kcLabelClass=pf-v5-c-form__label +kcInputClass=pf-v5-c-form-control +kcInputGroup=pf-v5-c-input-group +kcFormHelperTextClass=pf-v5-c-form__helper-text +kcInputHelperTextClass=pf-v5-c-helper-text +kcInputHelperTextItemClass=pf-v5-c-helper-text__item +kcInputHelperTextItemTextClass=pf-v5-c-helper-text__item-text +kcInputGroupItemClass=pf-v5-c-input-group__item +kcFill=pf-m-fill +kcError=pf-m-error +kcLoginFooterBand=pf-v5-c-login__main-footer-band +kcLoginFooterBandItem=pf-v5-c-login__main-footer-band-item + +kcCheckboxClass=pf-v5-c-check +kcCheckboxInputClass=pf-v5-c-check__input +kcCheckboxLabelClass=pf-v5-c-check__label +kcCheckboxLabelRequiredClass=pf-v5-c-check__label-required + +kcInputRequiredClass=pf-v5-c-form__label-required +kcInputErrorMessageClass=pf-v5-c-helper-text__item-text pf-m-error kc-feedback-text +kcFormControlUtilClass=pf-v5-c-form-control__utilities +kcInputErrorIconStatusClass=pf-v5-c-form-control__icon pf-m-status +kcInputErrorIconClass=fas fa-exclamation-circle +kcAlertClass=pf-v5-c-alert pf-m-inline pf-v5-u-mb-md +kcAlertIconClass=pf-v5-c-alert__icon +kcAlertTitleClass=pf-v5-c-alert__title +kcAlertDescriptionClass=pf-v5-c-alert__description +kcFormPasswordVisibilityButtonClass=pf-v5-c-button pf-m-control +kcFormControlToggleIcon=pf-v5-c-form-control__toggle-icon +kcFormActionGroupClass=pf-v5-c-form__actions +kcFormReadOnlyClass=pf-m-readonly +kcFormGroupLabelClass=pf-v5-c-form__label +kcFormGroupLabelTextClass=pf-v5-c-form__label-text + +kcPanelClass=pf-v5-c-panel pf-m-raised +kcPanelMainClass=pf-v5-c-panel__main +kcPanelMainBodyClass=pf-v5-c-panel__main-body +kcListClass=pf-v5-c-list + +kcButtonClass=pf-v5-c-button +kcButtonPrimaryClass=pf-v5-c-button pf-m-primary +kcButtonSecondaryClass=pf-v5-c-button pf-m-secondary +kcButtonBlockClass=pf-m-block +kcButtonLinkClass=pf-v5-c-button pf-m-link +kcCommonLogoIdP=pf-v5-c-login__main-footer-links-item +kcFormSocialAccountListClass=pf-v5-c-login__main-footer-links +kcFormSocialAccountListItemClass=pf-v5-c-login__main-footer-links-item +kcFormSocialAccountListButtonClass=pf-v5-c-login__main-footer-links-item-link + +kcLogoIdP-linkedin-openid-connect=fa fa-linkedin + +kcLoginClass=pf-v5-c-login__main +kcFormClass=pf-v5-c-form +kcFormCardClass=card-pf + +kcResetFlowIcon=pf-icon fas fa-share-square + +kcSelectAuthListClass=pf-v5-c-data-list select-auth-container +kcSelectAuthListItemWrapperClass=pf-v5-c-data-list__item pf-m-clickable +kcSelectAuthListItemClass=pf-v5-c-data-list__item-row select-auth-box-parent +kcSelectAuthListItemHeadingClass=pf-v5-u-font-family-heading select-auth-box-headline +kcSelectAuthListItemBodyClass=pf-v5-c-data-list__cell pf-m-no-fill +kcSelectAuthListItemIconClass=pf-v5-c-data-list__cell pf-m-icon select-auth-box-icon +kcSelectAuthListItemFillClass=pf-v5-c-data-list__item-action +kcSelectAuthListItemDescriptionClass=pf-v5-c-data-list__cell pf-m-no-fill select-auth-box-desc + +kcRecoveryCodesWarning=pf-v5-c-alert pf-m-warning pf-m-inline pf-v5-u-mb-md kc-recovery-codes-warning +kcLogin=pf-v5-c-login +kcLoginContainer=pf-v5-c-login__container +kcLoginMain=pf-v5-c-login__main +kcLoginMainHeader=pf-v5-c-login__main-header +kcLoginMainTitle=pf-v5-c-title pf-m-3xl +kcLoginMainHeaderUtilities=pf-v5-c-login__main-header-utilities +kcLoginMainBody=pf-v5-c-login__main-body + +kcContentWrapperClass=pf-v5-u-mb-md-on-md +kcWebAuthnDefaultIcon=pf-v5-c-icon pf-m-lg +kcMarginTopClass=pf-v5-u-mt-md-on-md + +kcLoginOTPListClass=pf-v5-c-tile +kcLoginOTPListItemHeaderClass=pf-v5-c-tile__header pf-m-stacked +kcLoginOTPListItemIconBodyClass=pf-v5-c-tile__icon +kcLoginOTPListItemIconClass=fa fa-mobile +kcLoginOTPListItemTitleClass=pf-v5-c-tile__title +kcLoginOTPListSelectedClass=pf-m-selected + +kcDarkModeClass=pf-v5-theme-dark + +kcHtmlClass=login-pf +kcLogoIdP-facebook= +kcLogoIdP-google= +kcLogoIdP-github= +kcLogoIdP-linkedin= +kcLogoIdP-instagram= +kcLogoIdP-microsoft= +kcLogoIdP-bitbucket= +kcLogoIdP-gitlab= +kcLogoIdP-paypal= +kcLogoIdP-stackoverflow= +kcLogoIdP-twitter= +kcLogoIdP-openshift-v4= diff --git a/docker/compose/keycloakserver/custom/login/user-profile-commons.ftl b/docker/compose/keycloakserver/custom/login/user-profile-commons.ftl new file mode 100644 index 0000000..ef1726c --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/user-profile-commons.ftl @@ -0,0 +1,219 @@ +<#import "field.ftl" as field> +<#macro userProfileFormFields> + <#assign currentGroup=""> + + <#list profile.attributes as attribute> + + <#assign group = (attribute.group)!""> + <#if group != currentGroup> + <#assign currentGroup=group> + <#if currentGroup != ""> +
+ data-${key}="${value}" + + > + + <#assign groupDisplayHeader=group.displayHeader!""> + <#if groupDisplayHeader != ""> + <#assign groupHeaderText=advancedMsg(groupDisplayHeader)!group> + <#else> + <#assign groupHeaderText=group.name!""> + +
+ +
+ + <#assign groupDisplayDescription=group.displayDescription!""> + <#if groupDisplayDescription != ""> + <#assign groupDescriptionText=advancedMsg(groupDisplayDescription)!""> +
+ +
+ +
+ + + + <#nested "beforeField" attribute> + + <@field.group name=attribute.name label=advancedMsg(attribute.displayName!'') error=kcSanitize(messagesPerField.get('${attribute.name}'))?no_esc required=attribute.required> +
+ <#if attribute.annotations.inputHelperTextBefore??> +
${kcSanitize(advancedMsg(attribute.annotations.inputHelperTextBefore))?no_esc}
+ + <@inputFieldByType attribute=attribute/> + <#if attribute.annotations.inputHelperTextAfter??> +
${kcSanitize(advancedMsg(attribute.annotations.inputHelperTextAfter))?no_esc}
+ +
+ + <#nested "afterField" attribute> + + + <#list profile.html5DataAnnotations?keys as key> + + + + +<#macro inputFieldByType attribute> + <#switch attribute.annotations.inputType!''> + <#case 'textarea'> + <@textareaTag attribute=attribute/> + <#break> + <#case 'select'> + <#case 'multiselect'> + <@selectTag attribute=attribute/> + <#break> + <#case 'select-radiobuttons'> + <#case 'multiselect-checkboxes'> + <@inputTagSelects attribute=attribute/> + <#break> + <#default> + <#if attribute.multivalued && attribute.values?has_content> + <#list attribute.values as value> + <@inputTag attribute=attribute value=value!''/> + + <#else> + <@inputTag attribute=attribute value=attribute.value!''/> + + + + +<#macro inputTag attribute value> + + disabled + <#if attribute.autocomplete??>autocomplete="${attribute.autocomplete}" + <#if attribute.annotations.inputTypePlaceholder??>placeholder="${advancedMsg(attribute.annotations.inputTypePlaceholder)}" + <#if attribute.annotations.inputTypePattern??>pattern="${attribute.annotations.inputTypePattern}" + <#if attribute.annotations.inputTypeSize??>size="${attribute.annotations.inputTypeSize}" + <#if attribute.annotations.inputTypeMaxlength??>maxlength="${attribute.annotations.inputTypeMaxlength}" + <#if attribute.annotations.inputTypeMinlength??>minlength="${attribute.annotations.inputTypeMinlength}" + <#if attribute.annotations.inputTypeMax??>max="${attribute.annotations.inputTypeMax}" + <#if attribute.annotations.inputTypeMin??>min="${attribute.annotations.inputTypeMin}" + <#if attribute.annotations.inputTypeStep??>step="${attribute.annotations.inputTypeStep}" + <#list attribute.html5DataAnnotations as key, value> + data-${key}="${value}" + + /> + + + +<#macro inputTagType attribute> + <#compress> + <#if attribute.annotations.inputType??> + <#if attribute.annotations.inputType?starts_with("html5-")> + ${attribute.annotations.inputType[6..]} + <#else> + ${attribute.annotations.inputType} + + <#else> + text + + + + +<#macro textareaTag attribute> + + + +<#macro selectTag attribute> +
+ + + + + + +
+ + +<#macro inputTagSelects attribute> + <#if attribute.annotations.inputType=='select-radiobuttons'> + <#assign inputType='radio'> + <#assign classDiv=properties.kcInputClassRadio!> + <#assign classInput=properties.kcInputClassRadioInput!> + <#assign classLabel=properties.kcInputClassRadioLabel!> + <#else> + + <#assign inputType='checkbox'> + <#assign classDiv=properties.kcInputClassCheckbox!> + <#assign classInput=properties.kcInputClassCheckboxInput!> + <#assign classLabel=properties.kcInputClassCheckboxLabel!> + + + <#if attribute.annotations.inputOptionsFromValidation?? && attribute.validators[attribute.annotations.inputOptionsFromValidation]?? && attribute.validators[attribute.annotations.inputOptionsFromValidation].options??> + <#assign options=attribute.validators[attribute.annotations.inputOptionsFromValidation].options> + <#elseif attribute.validators.options?? && attribute.validators.options.options??> + <#assign options=attribute.validators.options.options> + <#else> + <#assign options=[]> + + + <#list options as option> +
+ disabled + <#if attribute.values?seq_contains(option)>checked + /> + +
+ + + +<#macro selectOptionLabelText attribute option> + <#compress> + <#if attribute.annotations.inputOptionLabels??> + ${advancedMsg(attribute.annotations.inputOptionLabels[option]!option)} + <#else> + <#if attribute.annotations.inputOptionLabelsI18nPrefix??> + ${msg(attribute.annotations.inputOptionLabelsI18nPrefix + '.' + option)} + <#else> + ${option} + + + + diff --git a/docker/compose/keycloakserver/custom/login/webauthn-authenticate.ftl b/docker/compose/keycloakserver/custom/login/webauthn-authenticate.ftl new file mode 100644 index 0000000..e5b03eb --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/webauthn-authenticate.ftl @@ -0,0 +1,118 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout displayInfo=(realm.registrationAllowed && !registrationDisabled??); section> + + + <#if section = "title"> + title + <#elseif section = "header"> + ${kcSanitize(msg("webauthn-login-title"))?no_esc} + <#elseif section = "form"> +
+
+ + + + + + +
+ +
+ <#if authenticators??> +
+ <#list authenticators.authenticators as authenticator> + + +
+ + <#if shouldDisplayAuthenticators?? && shouldDisplayAuthenticators> + <#if authenticators.authenticators?size gt 1> +

${kcSanitize(msg("webauthn-available-authenticators"))?no_esc}

+ + +
    + <#list authenticators.authenticators as authenticator> +
  • +
    +
    +
    + <#switch authenticator.transports.iconClass> + <#case "kcWebAuthnBLE"> + + <#break> + <#case "kcWebAuthnNFC"> + + <#break> + <#case "kcWebAuthnUSB"> + + <#break> + <#default> + + <#break> + +
    +
    +
    +
    + ${kcSanitize(msg('${authenticator.label}'))?no_esc} +
    + + <#if authenticator.transports?? && authenticator.transports.displayNameProperties?has_content> +
    + <#list authenticator.transports.displayNameProperties as nameProperty> + ${kcSanitize(msg('${nameProperty!}'))?no_esc} + <#if nameProperty?has_next> + , + + +
    + + + + ${kcSanitize(msg('webauthn-createdAt-label'))?no_esc} + + + ${kcSanitize(authenticator.createdAt)?no_esc} + +
    +
    +
    +
  • + +
+ + + +
+ +
+
+
+ + + + <#elseif section = "info"> + <#if realm.registrationAllowed && !registrationDisabled??> +
+ ${msg("noAccount")} ${msg("doRegister")} +
+ + + diff --git a/docker/compose/keycloakserver/custom/login/webauthn-register.ftl b/docker/compose/keycloakserver/custom/login/webauthn-register.ftl new file mode 100644 index 0000000..7020045 --- /dev/null +++ b/docker/compose/keycloakserver/custom/login/webauthn-register.ftl @@ -0,0 +1,61 @@ +<#import "template.ftl" as layout> +<#import "password-commons.ftl" as passwordCommons> +<#import "buttons.ftl" as buttons> + +<@layout.registrationLayout; section> + <#if section = "title"> + title + <#elseif section = "header"> + + ${kcSanitize(msg("webauthn-registration-title"))?no_esc} + <#elseif section = "form"> + +
+
+ + + + + + + <@passwordCommons.logoutOtherSessions/> +
+
+ + + +
+ <@buttons.actionGroup> + <@buttons.button id="registerWebAuthn" label="doRegisterSecurityKey" /> + <#if !isSetRetry?has_content && isAppInitiatedAction?has_content> +
+ <@buttons.button id="cancelWebAuthnAIA" name="cancel-aia" label="doCancel" class=["kcButtonSecondaryClass"]/> +
+ + +
+ + diff --git a/docker/compose/logs_all.sh b/docker/compose/logs_all.sh new file mode 100644 index 0000000..5fd46e9 --- /dev/null +++ b/docker/compose/logs_all.sh @@ -0,0 +1,2 @@ +docker compose logs -f + diff --git a/docker/compose/logs_keycloakpostgres.sh b/docker/compose/logs_keycloakpostgres.sh new file mode 100644 index 0000000..f240718 --- /dev/null +++ b/docker/compose/logs_keycloakpostgres.sh @@ -0,0 +1,2 @@ +docker compose logs -f keycloakpostgres + diff --git a/docker/compose/logs_keycloakserver.sh b/docker/compose/logs_keycloakserver.sh new file mode 100644 index 0000000..6707aef --- /dev/null +++ b/docker/compose/logs_keycloakserver.sh @@ -0,0 +1,2 @@ +docker compose logs -f keycloakserver + diff --git a/docker/compose/logs_nginx.sh b/docker/compose/logs_nginx.sh new file mode 100644 index 0000000..e281259 --- /dev/null +++ b/docker/compose/logs_nginx.sh @@ -0,0 +1,2 @@ +docker compose logs -f nginx + diff --git a/docker/compose/nginx/compose.yaml b/docker/compose/nginx/compose.yaml new file mode 100644 index 0000000..f3ce227 --- /dev/null +++ b/docker/compose/nginx/compose.yaml @@ -0,0 +1,30 @@ +services: + nginx: + image: nginx:stable-alpine + container_name: nginx + hostname: nginx + restart: always + volumes: + - "./key.pem:/certs/nginx_key.pem:ro" + - "./ca.pem:/certs/nginx_certificate.pem:ro" + - "./nginx.conf:/etc/nginx/nginx.conf:ro" + ports: + - "0.0.0.0:443:443" + - "0.0.0.0:80:80" + environment: + NGINX_WORKER_PROCESSES: "4" + NGINX_WORKER_CONNECTIONS: "768" + networks: + - keycloak-network + + healthcheck: + test: ["CMD-SHELL", "curl -fsI --connect-timeout 10 http://localhost || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + +networks: + keycloak-network: + external: true + diff --git a/docker/compose/nginx/nginx.conf b/docker/compose/nginx/nginx.conf new file mode 100644 index 0000000..5ba2a52 --- /dev/null +++ b/docker/compose/nginx/nginx.conf @@ -0,0 +1,54 @@ +events {} +http { + server { + listen 80 default_server; + server_name _; + return 301 https://$host$request_uri; + } + + server { + listen 443 ssl; + ssl_certificate /certs/nginx_certificate.pem; + ssl_certificate_key /certs/nginx_key.pem; + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_prefer_server_ciphers on; + ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5; + add_header Strict-Transport-Security "max-age=31536000; includeSubdomains;"; + server_tokens off; + client_max_body_size 50M; + + location /sso { + proxy_pass http://keycloakserver:8080/sso; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Server $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://register:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Server $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /externals { + proxy_pass http://externals:80/externals; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Server $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} + diff --git a/docker/compose/register/Dockerfile b/docker/compose/register/Dockerfile new file mode 100644 index 0000000..18cb790 --- /dev/null +++ b/docker/compose/register/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12.5 + +RUN apt-get update +RUN apt -y install mc +RUN apt -y install docker.io +RUN pip install pymongo +RUN pip install email_validator +RUN pip install flask +RUN pip install gunicorn +RUN pip install requests +RUN pip install BeautifulSoup4 +RUN apt -y install bash +RUN pip install --upgrade pip +RUN pip install flask_wtf +RUN pip install wtforms +RUN pip install flask_recaptcha +RUN pip install Markup +RUN pip install captcha Pillow + +EXPOSE 80 + +ENTRYPOINT ["/bin/bash", "-c", "cd data && gunicorn wsgi:app --bind 0.0.0.0:80"] + diff --git a/docker/compose/register/compose.yaml b/docker/compose/register/compose.yaml new file mode 100644 index 0000000..c494d92 --- /dev/null +++ b/docker/compose/register/compose.yaml @@ -0,0 +1,29 @@ +services: + register: + image: "register_image" + container_name: register + hostname: register + restart: always + + networks: + - keycloak-network + + volumes: + - ./data:/data + - /var/run/docker.sock:/var/run/docker.sock + + healthcheck: + test: bash -c "curl -fs --connect-timeout 10 http://localhost/ || exit 1" + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + +# entrypoint: ["sh", "-c", "sleep infinity"] + +networks: + + keycloak-network: + external: true + + diff --git a/docker/compose/register/data/add_user.py b/docker/compose/register/data/add_user.py new file mode 100644 index 0000000..69ccfaf --- /dev/null +++ b/docker/compose/register/data/add_user.py @@ -0,0 +1,74 @@ +import requests # type: ignore +import json +from requests.auth import HTTPBasicAuth # type: ignore + + +def add_keycloak_user(username) -> tuple[bool, str]: + + with open("config.json", "r") as file: + config = json.load(file) + + token_url = f"{config['keycloak_url']}/realms/master/protocol/openid-connect/token" + token_data = { + "grant_type": "password", + "username": config["admin_username"], + "password": config["admin_password"], + } + users_url = f"{config['keycloak_url']}/admin/realms/master/users" + + # Get token + try: + response = requests.post( + token_url, + data=token_data, + auth=HTTPBasicAuth(config["client_id"], config["client_secret"]), + ) + response.raise_for_status() + + except requests.exceptions.HTTPError: + return False, "SSO connection broken. No token." + + access_token = response.json()["access_token"] + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + # Check if user exists + params = {"username": username, "exact": "true"} + + try: + response = requests.get(users_url, headers=headers, params=params) + response.raise_for_status() + + # Response is a list of users matching the criteria + users = response.json() + + # If we found any users with exact username match, the user exists + if len(users) > 0: + return False, f"User {username} already exists." + + except requests.exceptions.HTTPError: + return False, "Communication with SSO server failed." + + # Make new user + new_user = { + "username": username, + "enabled": True, + "emailVerified": False, + "firstName": " ", + "lastName": " ", + "email": username, + "requiredActions": ["UPDATE_PASSWORD"], + } + + try: + # Create the user + response = requests.post(users_url, headers=headers, data=json.dumps(new_user)) + response.raise_for_status() + + except requests.exceptions.HTTPError: + return False, f"User {username} creation failed on the SSO server." + + return True, "" + diff --git a/docker/compose/register/data/allowed_domains.json b/docker/compose/register/data/allowed_domains.json new file mode 100644 index 0000000..7316428 --- /dev/null +++ b/docker/compose/register/data/allowed_domains.json @@ -0,0 +1,6 @@ +{ + "allowed_domains": [ + "uni-bremen.de", "marum.de", "awi.de" + ] +} + diff --git a/docker/compose/register/data/blocked_users.json b/docker/compose/register/data/blocked_users.json new file mode 100644 index 0000000..60d6660 --- /dev/null +++ b/docker/compose/register/data/blocked_users.json @@ -0,0 +1,6 @@ +{ + "blocked_users": [ + "" + ] +} + diff --git a/docker/compose/register/data/config.json b/docker/compose/register/data/config.json new file mode 100644 index 0000000..334eaeb --- /dev/null +++ b/docker/compose/register/data/config.json @@ -0,0 +1,9 @@ +{ + "keycloak_url": "https://sso.fb1.uni-bremen.de/sso", + "keycloak_login": "https://sso.fb1.uni-bremen.de/sso", + "admin_username": "automation@non.no", + "admin_password": "REDACTED", + "client_id": "admin-cli", + "client_secret": "REDACTED" +} + diff --git a/docker/compose/register/data/main.py b/docker/compose/register/data/main.py new file mode 100644 index 0000000..8169b9e --- /dev/null +++ b/docker/compose/register/data/main.py @@ -0,0 +1,65 @@ +import json +from flask import ( + Flask, + render_template, + request, + Response, + send_from_directory, + session, + redirect, +) +from io import BytesIO +from captcha.image import ImageCaptcha +import random +import base64 +from process_emails import process_emails + +with open("config.json", "r") as file: + config: dict = json.load(file) + +keycloak_url: str = config["keycloak_login"] +app = Flask(__name__) + +with open("secret_key.json", "r") as file: + secret_key: dict = json.load(file) + +assert secret_key is not None +assert secret_key["secret_key"] is not None +app.config["SECRET_KEY"] = secret_key["secret_key"] + + +def generate_captcha(): + image = ImageCaptcha(width=280, height=90) + # I simplied the Captcha + captcha_text = "".join(random.choices("A", k=6)) + data = image.generate(captcha_text) + return captcha_text, data + + +@app.route("/", methods=["GET", "POST"]) +def index() -> Response: + + if request.method == "GET": + captcha_text, captcha_image = generate_captcha() + session["captcha"] = captcha_text + captcha_base64 = base64.b64encode(captcha_image.getvalue()).decode("utf-8") + return render_template("post.html", captcha_image=captcha_base64) + + elif request.method == "POST": + email = request.form.get("email") + user_captcha = request.form.get("captcha") + + if user_captcha and user_captcha.upper() == session.get("captcha"): + status_okay, error_string = process_emails(mail_address=email) + if status_okay: + return redirect(keycloak_url) + else: + return f"

Failure :-(

We couldn't register your email {email}.

Reason:

{error_string}

back to the register form..." + else: + return "

Failure :-(

There was a problem with solving the captcha. Try again. Sorry!

back to the register form..." + + +@app.route("/static/", methods=["GET"]) +def serve_static_files(path) -> Response: + return send_from_directory("static", path) + diff --git a/docker/compose/register/data/process_emails.py b/docker/compose/register/data/process_emails.py new file mode 100644 index 0000000..d42f627 --- /dev/null +++ b/docker/compose/register/data/process_emails.py @@ -0,0 +1,43 @@ +from email_validator import validate_email # type: ignore +import email_validator +import json + +from add_user import add_keycloak_user + +def process_emails( + mail_address: str, + config_file: str = "allowed_domains.json", + blocked_user_file: str = "blocked_users.json", +) -> tuple[bool, str]: + + with open(config_file, "r") as file: + allowed_domains: dict = json.load(file) + + with open(blocked_user_file, "r") as file: + blocked_users: dict = json.load(file) + + if (mail_address == "") or (mail_address is None): + return False, "eMail address empty or missing." + try: + emailinfo = validate_email(mail_address, check_deliverability=False) + mail_address = emailinfo.normalized + except email_validator.exceptions_types.EmailSyntaxError: + return False, f"{mail_address} -- eMail address failed validate_email (EmailSyntaxError)" + except email_validator.exceptions_types.EmailNotValidError: + return False, f"{mail_address} -- eMail address failed validate_email (EmailNotValidError)" + + for blocked_user in blocked_users["blocked_users"]: + if mail_address == blocked_user: + return False, f"{mail_address} -- eMail address is listed as blocked." + + domain_found: bool = False + for domain in allowed_domains["allowed_domains"]: + if mail_address.endswith(domain): + domain_found = True + print(f"{mail_address} -- domain was found") + + if domain_found is False: + return False, f"{mail_address} -- domain of eMail address is not on allowed domain list." + + return add_keycloak_user(mail_address) + diff --git a/docker/compose/register/data/run.sh b/docker/compose/register/data/run.sh new file mode 100644 index 0000000..06a6d69 --- /dev/null +++ b/docker/compose/register/data/run.sh @@ -0,0 +1,2 @@ +gunicorn wsgi:app --bind 0.0.0.0:80 + diff --git a/docker/compose/register/data/secret_key.json b/docker/compose/register/data/secret_key.json new file mode 100644 index 0000000..8298976 --- /dev/null +++ b/docker/compose/register/data/secret_key.json @@ -0,0 +1,4 @@ +{ + "secret_key": "REDACTED" +} + diff --git a/docker/compose/register/data/static/UNI_Logo.svg b/docker/compose/register/data/static/UNI_Logo.svg new file mode 100644 index 0000000..c8afb4e --- /dev/null +++ b/docker/compose/register/data/static/UNI_Logo.svg @@ -0,0 +1,50 @@ + + + + diff --git a/docker/compose/register/data/templates/post.html b/docker/compose/register/data/templates/post.html new file mode 100644 index 0000000..2d5e291 --- /dev/null +++ b/docker/compose/register/data/templates/post.html @@ -0,0 +1,117 @@ + + + + + + + Register your FB1 account + + + +

+ University of Bremen Logo +
+ +

Register your FB1 account

+ This is the FB1 SSO server for +

+ + + + +
FB1 git/forgejo server
FB1 overleaf server
PIP overleaf Server
+ +

Who can register?

+ You don't want to use your @uni-bremen.de account and have a +

+ + + + +
@XXX.uni-bremen.de
@marum.de
@awi.de
+
+ email account?

+ + In this case, your are at the right place.

+ Use this form and afterwards set your password via the "Forgot Password?" option during the login process.

+ +

+
+ + +
+

+

+ + +
+ Please enter the following six letters: AAAAAA +

+ +

+ +

You want to register an external guest?

+ + If you have already your own account here, you can add research partners by going here:

+ Register an external guest + + + + diff --git a/docker/compose/register/data/wsgi.py b/docker/compose/register/data/wsgi.py new file mode 100644 index 0000000..9e7cbee --- /dev/null +++ b/docker/compose/register/data/wsgi.py @@ -0,0 +1,5 @@ +from main import app + +if __name__ == "__main__": + app.run(debug=True) + diff --git a/docker/compose/register/down.sh b/docker/compose/register/down.sh new file mode 100644 index 0000000..c864209 --- /dev/null +++ b/docker/compose/register/down.sh @@ -0,0 +1,2 @@ +docker compose down + diff --git a/docker/compose/register/logs.sh b/docker/compose/register/logs.sh new file mode 100644 index 0000000..5fd46e9 --- /dev/null +++ b/docker/compose/register/logs.sh @@ -0,0 +1,2 @@ +docker compose logs -f + diff --git a/docker/compose/register/make_image.sh b/docker/compose/register/make_image.sh new file mode 100644 index 0000000..40f0db5 --- /dev/null +++ b/docker/compose/register/make_image.sh @@ -0,0 +1,2 @@ +docker build --network host -t register_image . + diff --git a/docker/compose/register/up.sh b/docker/compose/register/up.sh new file mode 100644 index 0000000..6da72b7 --- /dev/null +++ b/docker/compose/register/up.sh @@ -0,0 +1,3 @@ +docker compose down +docker compose up -d + diff --git a/docker/compose/up.sh b/docker/compose/up.sh new file mode 100644 index 0000000..a08baca --- /dev/null +++ b/docker/compose/up.sh @@ -0,0 +1,13 @@ +docker compose down + +docker network create keycloak-network +snetz=`docker network inspect keycloak-network | grep "Subnet" | sed s/" "/""/g | sed s/"\,"/""/g | sed s/":"/"\n"/g | grep -v "Subnet" | sed s/'"'/''/g` +nid=`docker network ls | grep keycloak-network | awk '{print $1}'` + +ufw allow in on br-$nid +ufw route allow in on br-$nid +ufw route allow out on br-$nid +iptables -t nat -A POSTROUTING ! -o br-$nid -s $snetz -j MASQUERADE + +docker compose up -d + diff --git a/docker/compose/up_nginx.sh b/docker/compose/up_nginx.sh new file mode 100644 index 0000000..14754ae --- /dev/null +++ b/docker/compose/up_nginx.sh @@ -0,0 +1,2 @@ +docker compose up -d nginx +