Upload files to "/"
This commit is contained in:
commit
51371516b6
83 changed files with 3370 additions and 0 deletions
2
docker/backup/copy_keys_over.sh
Normal file
2
docker/backup/copy_keys_over.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
scp backup.pub overleaf@backup.zfn.uni-bremen.de:~/.ssh/authorized_keys
|
||||
|
7
docker/backup/make_backup.sh
Normal file
7
docker/backup/make_backup.sh
Normal file
|
@ -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/
|
||||
|
2
docker/backup/make_keys.sh
Normal file
2
docker/backup/make_keys.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
ssh-keygen -t ed25519 -f backup
|
||||
|
16
docker/check_docker.sh
Normal file
16
docker/check_docker.sh
Normal file
|
@ -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
|
||||
|
47
docker/compose/compose.yaml
Normal file
47
docker/compose/compose.yaml
Normal file
|
@ -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
|
||||
|
2
docker/compose/down.sh
Normal file
2
docker/compose/down.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker compose down
|
||||
|
2
docker/compose/down_nginx.sh
Normal file
2
docker/compose/down_nginx.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker compose down nginx
|
||||
|
2
docker/compose/exec_keycloakpostgres.sh
Normal file
2
docker/compose/exec_keycloakpostgres.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker exec -it keycloakpostgres bash
|
||||
|
2
docker/compose/exec_keycloakserver.sh
Normal file
2
docker/compose/exec_keycloakserver.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker exec -it keycloakserver bash
|
||||
|
2
docker/compose/exec_nginx.sh
Normal file
2
docker/compose/exec_nginx.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker exec -it nginx sh
|
||||
|
23
docker/compose/externals/Dockerfile
vendored
Normal file
23
docker/compose/externals/Dockerfile
vendored
Normal file
|
@ -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"]
|
||||
|
29
docker/compose/externals/compose.yaml
vendored
Normal file
29
docker/compose/externals/compose.yaml
vendored
Normal file
|
@ -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
|
||||
|
||||
|
74
docker/compose/externals/data/add_user.py
vendored
Normal file
74
docker/compose/externals/data/add_user.py
vendored
Normal file
|
@ -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, ""
|
||||
|
6
docker/compose/externals/data/blocked_users.json
vendored
Normal file
6
docker/compose/externals/data/blocked_users.json
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"blocked_users": [
|
||||
""
|
||||
]
|
||||
}
|
||||
|
15
docker/compose/externals/data/config.json
vendored
Normal file
15
docker/compose/externals/data/config.json
vendored
Normal file
|
@ -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"
|
||||
}
|
||||
|
117
docker/compose/externals/data/main.py
vendored
Normal file
117
docker/compose/externals/data/main.py
vendored
Normal file
|
@ -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"<h2>{email} added to the FB1 SSO users!</h2>")
|
||||
else:
|
||||
return f"<h1>Failure :-(</h1> We couldn't register your email {email}. <br> <h2>Reason:</h2>{error_string} <p><a href=\"https://sso.fb1.uni-bremen.de/externals\">Back to the register form...</a>"
|
||||
|
||||
@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/<path:path>", 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)
|
||||
|
30
docker/compose/externals/data/process_emails.py
vendored
Normal file
30
docker/compose/externals/data/process_emails.py
vendored
Normal file
|
@ -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)
|
||||
|
50
docker/compose/externals/data/static/UNI_Logo.svg
vendored
Normal file
50
docker/compose/externals/data/static/UNI_Logo.svg
vendored
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 7.1 KiB |
100
docker/compose/externals/data/templates/post.html
vendored
Normal file
100
docker/compose/externals/data/templates/post.html
vendored
Normal file
|
@ -0,0 +1,100 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Register your FB1 guest's account</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
margin-bottom: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input[type="email"] {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 150px;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.status-marker {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img src="/externals/static/UNI_Logo.svg" alt="University of Bremen Logo" style="max-width: 200px;">
|
||||
</header>
|
||||
|
||||
<div class="user-info">
|
||||
Welcome, {{ username }} | <a href="{{ url_for('externals.logout') }}">Logout</a>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
|
||||
{{ added_email|safe }}
|
||||
<h1>Register your FB1 guest's account</h1>
|
||||
|
||||
|
||||
Use this form and afterwards the guest needs to use the password via the "Forgot Password?" option during the login process.<p>
|
||||
|
||||
<form method="POST" id="demo-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email:</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
<p>
|
||||
<button type="submit" class="submit-btn">Register</button>
|
||||
</form>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
5
docker/compose/externals/data/wsgi.py
vendored
Normal file
5
docker/compose/externals/data/wsgi.py
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
from main import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
|
2
docker/compose/externals/down.sh
vendored
Normal file
2
docker/compose/externals/down.sh
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker compose down
|
||||
|
2
docker/compose/externals/logs.sh
vendored
Normal file
2
docker/compose/externals/logs.sh
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker compose logs -f
|
||||
|
2
docker/compose/externals/make_image.sh
vendored
Normal file
2
docker/compose/externals/make_image.sh
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker build --network host -t externals_image .
|
||||
|
3
docker/compose/externals/up.sh
vendored
Normal file
3
docker/compose/externals/up.sh
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
docker compose down
|
||||
docker compose up -d
|
||||
|
2
docker/compose/keycloakpostgres/backup.sh
Normal file
2
docker/compose/keycloakpostgres/backup.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker exec keycloakpostgres bash -c "pg_dump -U keycloakuser -d keycloak -F c -f /backup/backup.sql"
|
||||
|
26
docker/compose/keycloakpostgres/compose.yaml
Normal file
26
docker/compose/keycloakpostgres/compose.yaml
Normal file
|
@ -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
|
||||
|
51
docker/compose/keycloakserver/compose.yaml
Normal file
51
docker/compose/keycloakserver/compose.yaml
Normal file
|
@ -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
|
||||
|
21
docker/compose/keycloakserver/custom/login/buttons.ftl
Normal file
21
docker/compose/keycloakserver/custom/login/buttons.ftl
Normal file
|
@ -0,0 +1,21 @@
|
|||
<#macro actionGroup>
|
||||
<div class="${properties.kcFormGroupClass}">
|
||||
<div class="${properties.kcFormActionGroupClass}">
|
||||
<#nested>
|
||||
</div>
|
||||
</div>
|
||||
</#macro>
|
||||
|
||||
<#macro button label id="" name="" class=["kcButtonPrimaryClass"]>
|
||||
<button class="<#list class as c>${properties[c]} </#list>" name="${name}" id="${id}" type="submit">${msg(label)}</button>
|
||||
</#macro>
|
||||
|
||||
<#macro buttonLink href label id="" class=["kcButtonSecondaryClass"]>
|
||||
<a id="${id}" href="${href}" class="<#list class as c>${properties[c]} </#list>">${kcSanitize(msg(label))?no_esc}</a>
|
||||
</#macro>
|
||||
|
||||
<#macro loginButton>
|
||||
<@buttons.actionGroup>
|
||||
<@buttons.button id="kc-login" name="login" label="doLogIn" class=["kcButtonPrimaryClass", "kcButtonBlockClass"] />
|
||||
</@buttons.actionGroup>
|
||||
</#macro>
|
20
docker/compose/keycloakserver/custom/login/code.ftl
Normal file
20
docker/compose/keycloakserver/custom/login/code.ftl
Normal file
|
@ -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))}
|
||||
</#if>
|
||||
<#elseif section = "form">
|
||||
<div id="kc-code">
|
||||
<#if code.success>
|
||||
<p>${msg("copyCodeInstruction")}</p>
|
||||
<@field.input name="code" label="" value=code.code />
|
||||
<#else>
|
||||
<p id="error">${kcSanitize(code.error)}</p>
|
||||
</#if>
|
||||
</div>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
|
@ -0,0 +1,40 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "buttons.ftl" as buttons>
|
||||
|
||||
<@layout.registrationLayout; section>
|
||||
<!-- template: delete-account-confirm.ftl -->
|
||||
|
||||
<#if section = "header">
|
||||
${msg("deleteAccountConfirm")}
|
||||
|
||||
<#elseif section = "form">
|
||||
|
||||
<form action="${url.loginAction}" class="${properties.kcFormClass!}" id="kc-deleteaccount-form" method="post">
|
||||
|
||||
<div class="${properties.kcAlertClass!} pf-m-warning">
|
||||
<div class="${properties.kcAlertIconClass!}">
|
||||
<i class="${properties.kcFeedbackWarningIcon!}" aria-hidden="true"></i>
|
||||
</div>
|
||||
<span class="${properties.kcAlertTitleClass!}">
|
||||
${msg("irreversibleAction")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p>${msg("deletingImplies")}</p>
|
||||
<ul class="pf-v5-c-list" role="list">
|
||||
<li>${msg("loggingOutImmediately")}</li>
|
||||
<li>${msg("errasingData")}</li>
|
||||
</ul>
|
||||
|
||||
<p class="delete-account-text">${msg("finalDeletionConfirmation")}</p>
|
||||
|
||||
<@buttons.actionGroup>
|
||||
<@buttons.button label="doConfirmDelete" class=["kcButtonPrimaryClass"]/>
|
||||
<#if triggered_from_aia>
|
||||
<@buttons.button name="cancel-aia" label="doCancel" class=["kcButtonSecondaryClass"]/>
|
||||
</#if>
|
||||
</@buttons.actionGroup>
|
||||
</form>
|
||||
</#if>
|
||||
|
||||
</@layout.registrationLayout>
|
|
@ -0,0 +1,21 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "buttons.ftl" as buttons>
|
||||
|
||||
<@layout.registrationLayout displayMessage=false; section>
|
||||
<!-- template: delete-credential.ftl -->
|
||||
|
||||
<#if section = "header">
|
||||
${msg("deleteCredentialTitle", credentialLabel)}
|
||||
<#elseif section = "form">
|
||||
<div id="kc-delete-text" class="${properties.kcContentWrapperClass!}">
|
||||
${msg("deleteCredentialMessage", credentialLabel)}
|
||||
</div>
|
||||
|
||||
<form class="${properties.kcFormClass!}" action="${url.loginAction}" method="POST">
|
||||
<@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"]/>
|
||||
</@buttons.actionGroup>
|
||||
</form
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
103
docker/compose/keycloakserver/custom/login/field.ftl
Normal file
103
docker/compose/keycloakserver/custom/login/field.ftl
Normal file
|
@ -0,0 +1,103 @@
|
|||
<#macro group name label error="" required=false>
|
||||
|
||||
<div class="${properties.kcFormGroupClass}">
|
||||
<div class="${properties.kcFormGroupLabelClass}">
|
||||
<label for="${name}" class="${properties.kcFormGroupLabelClass}">
|
||||
<span class="${properties.kcFormGroupLabelTextClass}">
|
||||
${label}
|
||||
</span>
|
||||
<#if required>
|
||||
<span class="${properties.kcInputRequiredClass}" aria-hidden="true">*</span>
|
||||
</#if>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<#nested>
|
||||
|
||||
<div id="input-error-container-${name}">
|
||||
<#if error?has_content>
|
||||
<div class="${properties.kcFormHelperTextClass}" aria-live="polite">
|
||||
<div class="${properties.kcInputHelperTextClass}">
|
||||
<div class="${properties.kcInputHelperTextItemClass} ${properties.kcError}" id="input-error-${name}">
|
||||
<span class="${properties.kcInputErrorMessageClass}">
|
||||
${error}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</#if>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</#macro>
|
||||
|
||||
<#macro errorIcon error="">
|
||||
<#if error?has_content>
|
||||
<span class="${properties.kcFormControlUtilClass}">
|
||||
<span class="${properties.kcInputErrorIconStatusClass}">
|
||||
<i class="${properties.kcInputErrorIconClass}" aria-hidden="true"></i>
|
||||
</span>
|
||||
</span>
|
||||
</#if>
|
||||
</#macro>
|
||||
|
||||
<#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>
|
||||
<span class="${properties.kcInputClass} <#if error?has_content>${properties.kcError}</#if>">
|
||||
<input id="${name}" name="${name}" value="${value}" type="text" autocomplete="${autocomplete}" <#if autofocus>autofocus</#if>
|
||||
aria-invalid="<#if error?has_content>true</#if>"/>
|
||||
<@errorIcon error=error/>
|
||||
</span>
|
||||
</@group>
|
||||
</#macro>
|
||||
|
||||
<#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>
|
||||
<div class="${properties.kcInputGroup}">
|
||||
<div class="${properties.kcInputGroupItemClass} ${properties.kcFill}">
|
||||
<span class="${properties.kcInputClass} <#if error?has_content>${properties.kcError}</#if>">
|
||||
<input id="${name}" name="${name}" value="${value}" type="password" autocomplete="${autocomplete}" <#if autofocus>autofocus</#if>
|
||||
aria-invalid="<#if error?has_content>true</#if>"/>
|
||||
<@errorIcon error=error/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="${properties.kcInputGroupItemClass}">
|
||||
<button class="${properties.kcFormPasswordVisibilityButtonClass}" type="button" aria-label="${msg('showPassword')}"
|
||||
aria-controls="${name}" data-password-toggle
|
||||
data-icon-show="fa-eye fas" data-icon-hide="fa-eye-slash fas"
|
||||
data-label-show="${msg('showPassword')}" data-label-hide="${msg('hidePassword')}">
|
||||
<i class="fa-eye fas" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<#if forgotPassword>
|
||||
<div class="${properties.kcFormHelperTextClass}" aria-live="polite">
|
||||
<div class="${properties.kcInputHelperTextClass}">
|
||||
<div class="${properties.kcInputHelperTextItemClass}">
|
||||
<span class="${properties.kcInputHelperTextItemTextClass}">
|
||||
<a href="${url.loginResetCredentialsUrl}">${msg("doForgotPassword")}</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</#if>
|
||||
</@group>
|
||||
</#macro>
|
||||
|
||||
<#macro checkbox name label value=false required=false>
|
||||
<div class="${properties.kcCheckboxClass}">
|
||||
<label for="${name}" class="${properties.kcCheckboxClass}">
|
||||
<input
|
||||
class="${properties.kcCheckboxInputClass}"
|
||||
type="checkbox"
|
||||
id="${name}"
|
||||
name="${name}"
|
||||
<#if value>checked</#if>
|
||||
/>
|
||||
<span class="${properties.kcCheckboxLabelClass}">${label}</span>
|
||||
<#if required>
|
||||
<span class="${properties.kcCheckboxLabelRequiredClass}" aria-hidden="true">*</span>
|
||||
</#if>
|
||||
</label>
|
||||
</div>
|
||||
</#macro>
|
123
docker/compose/keycloakserver/custom/login/login-config-totp.ftl
Normal file
123
docker/compose/keycloakserver/custom/login/login-config-totp.ftl
Normal file
|
@ -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>
|
||||
<!-- template: login-config-totp.ftl -->
|
||||
|
||||
<#if section = "header">
|
||||
${msg("loginTotpTitle")}
|
||||
<#elseif section = "form">
|
||||
<ol id="kc-totp-settings" class="pf-v5-c-list pf-v5-u-mb-md">
|
||||
<li>
|
||||
<p>${msg("loginTotpStep1")}</p>
|
||||
|
||||
<ul id="kc-totp-supported-apps">
|
||||
<#list totp.supportedApplications as app>
|
||||
<li>${msg(app)}</li>
|
||||
</#list>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<#if mode?? && mode = "manual">
|
||||
<li>
|
||||
<p>${msg("loginTotpManualStep2")}</p>
|
||||
<p><span id="kc-totp-secret-key">${totp.totpSecretEncoded}</span></p>
|
||||
<p><a href="${totp.qrUrl}" id="mode-barcode">${msg("loginTotpScanBarcode")}</a></p>
|
||||
</li>
|
||||
<li>
|
||||
<p>${msg("loginTotpManualStep3")}</p>
|
||||
<p>
|
||||
<ul>
|
||||
<li id="kc-totp-type">${msg("loginTotpType")}: ${msg("loginTotp." + totp.policy.type)}</li>
|
||||
<li id="kc-totp-algorithm">${msg("loginTotpAlgorithm")}: ${totp.policy.getAlgorithmKey()}</li>
|
||||
<li id="kc-totp-digits">${msg("loginTotpDigits")}: ${totp.policy.digits}</li>
|
||||
<#if totp.policy.type = "totp">
|
||||
<li id="kc-totp-period">${msg("loginTotpInterval")}: ${totp.policy.period}</li>
|
||||
<#elseif totp.policy.type = "hotp">
|
||||
<li id="kc-totp-counter">${msg("loginTotpCounter")}: ${totp.policy.initialCounter}</li>
|
||||
</#if>
|
||||
</ul>
|
||||
</p>
|
||||
</li>
|
||||
<#else>
|
||||
<li>
|
||||
<p>${msg("loginTotpStep2")}</p>
|
||||
<img id="kc-totp-secret-qr-code" src="data:image/png;base64, ${totp.totpSecretQrCode}" alt="Figure: Barcode"><br/>
|
||||
<p><a href="${totp.manualUrl}" id="mode-manual">${msg("loginTotpUnableToScan")}</a></p>
|
||||
</li>
|
||||
</#if>
|
||||
<li>
|
||||
<p>${msg("loginTotpStep3")}</p>
|
||||
<p>${msg("loginTotpStep3DeviceName")}</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<form action="${url.loginAction}" class="${properties.kcFormClass!}" id="kc-totp-settings-form" method="post" novalidate="novalidate">
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<div class="${properties.kcLabelClass!}">
|
||||
<label class="pf-v5-c-form__label" for="form-vertical-name">
|
||||
<span class="pf-v5-c-form__label-text">${msg("authenticatorCode")}</span> <span class="pf-v5-c-form__label-required" aria-hidden="true">*</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="${properties.kcInputClass!} <#if messagesPerField.existsError('totp')>pf-m-error</#if>">
|
||||
<input type="text" required id="totp" name="totp" autocomplete="off"
|
||||
aria-invalid="<#if messagesPerField.existsError('totp')>true</#if>"
|
||||
/>
|
||||
|
||||
<@field.errorIcon error=kcSanitize(messagesPerField.get('totp'))?no_esc/>
|
||||
</div>
|
||||
<#if messagesPerField.existsError('totp')>
|
||||
<span id="input-error-otp-code" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
|
||||
${kcSanitize(messagesPerField.get('totp'))?no_esc}
|
||||
</span>
|
||||
</#if>
|
||||
<input type="hidden" id="totpSecret" name="totpSecret" value="${totp.totpSecret}" />
|
||||
<#if mode??><input type="hidden" id="mode" name="mode" value="${mode}"/></#if>
|
||||
</div>
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<div class="${properties.kcLabelClass!}">
|
||||
<label class="pf-v5-c-form__label" for="form-vertical-name">
|
||||
<span class="pf-v5-c-form__label-text">${msg("loginTotpDeviceName")}</span><#if totp.otpCredentials?size gte 1> <span class="pf-v5-c-form__label-required" aria-hidden="true">*</span></#if>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="${properties.kcInputClass!}">
|
||||
<input type="text" id="userLabel" name="userLabel" autocomplete="off"
|
||||
aria-invalid="<#if messagesPerField.existsError('userLabel')>true</#if>"
|
||||
/>
|
||||
|
||||
<@field.errorIcon error=kcSanitize(messagesPerField.get('userLabel'))?no_esc/>
|
||||
</div>
|
||||
<#if messagesPerField.existsError('userLabel')>
|
||||
<span id="input-error-otp-label" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
|
||||
${kcSanitize(messagesPerField.get('userLabel'))?no_esc}
|
||||
</span>
|
||||
</#if>
|
||||
</div>
|
||||
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<@passwordCommons.logoutOtherSessions/>
|
||||
</div>
|
||||
|
||||
<div class="pf-v5-c-form__group pf-m-action">
|
||||
<div class="pf-v5-c-form__actions">
|
||||
<#if isAppInitiatedAction??>
|
||||
<input type="submit"
|
||||
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}"
|
||||
id="saveTOTPBtn" value="${msg("doSubmit")}"
|
||||
/>
|
||||
<button type="submit"
|
||||
class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!} ${properties.kcButtonLargeClass!}"
|
||||
id="cancelTOTPBtn" name="cancel-aia" value="true" />${msg("doCancel")}
|
||||
</button>
|
||||
<#else>
|
||||
<input type="submit"
|
||||
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
|
||||
id="saveTOTPBtn" value="${msg("doSubmit")}"
|
||||
/>
|
||||
</#if>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
|
@ -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??>
|
||||
<img src="${client.attributes.logoUri}"/>
|
||||
</#if>
|
||||
<p>
|
||||
<#if client.name?has_content>
|
||||
${msg("oauthGrantTitle",advancedMsg(client.name))}
|
||||
<#else>
|
||||
${msg("oauthGrantTitle",client.clientId)}
|
||||
</#if>
|
||||
</p>
|
||||
<#elseif section = "form">
|
||||
<div id="kc-oauth" class="content-area">
|
||||
<h3>${msg("oauthGrantRequest")}</h3>
|
||||
<ul class="${properties.kcListClass!}">
|
||||
<#if oauth.clientScopesRequested??>
|
||||
<#list oauth.clientScopesRequested as clientScope>
|
||||
<li>
|
||||
<span><#if !clientScope.dynamicScopeParameter??>
|
||||
${advancedMsg(clientScope.consentScreenText)}
|
||||
<#else>
|
||||
${advancedMsg(clientScope.consentScreenText)}: <b>${clientScope.dynamicScopeParameter}</b>
|
||||
</#if>
|
||||
</span>
|
||||
</li>
|
||||
</#list>
|
||||
</#if>
|
||||
</ul>
|
||||
<#if client.attributes.policyUri?? || client.attributes.tosUri??>
|
||||
<h3>
|
||||
<#if client.name?has_content>
|
||||
${msg("oauthGrantInformation",advancedMsg(client.name))}
|
||||
<#else>
|
||||
${msg("oauthGrantInformation",client.clientId)}
|
||||
</#if>
|
||||
<#if client.attributes.tosUri??>
|
||||
${msg("oauthGrantReview")}
|
||||
<a href="${client.attributes.tosUri}" target="_blank">${msg("oauthGrantTos")}</a>
|
||||
</#if>
|
||||
<#if client.attributes.policyUri??>
|
||||
${msg("oauthGrantReview")}
|
||||
<a href="${client.attributes.policyUri}" target="_blank">${msg("oauthGrantPolicy")}</a>
|
||||
</#if>
|
||||
</h3>
|
||||
</#if>
|
||||
|
||||
<form class="${properties.kcFormClass} ${properties.kcMarginTopClass!}" action="${url.oauthAction}" method="POST">
|
||||
<input type="hidden" name="code" value="${oauth.code}">
|
||||
<@buttons.actionGroup>
|
||||
<@buttons.button id="kc-login" name="accept" label="doYes"/>
|
||||
<@buttons.button id="kc-cancel" name="cancel" label="doNo" class=["kcButtonSecondaryClass"]/>
|
||||
</@buttons.actionGroup>
|
||||
</form>
|
||||
</div>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
45
docker/compose/keycloakserver/custom/login/login-otp.ftl
Normal file
45
docker/compose/keycloakserver/custom/login/login-otp.ftl
Normal file
|
@ -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>
|
||||
<!-- template: login-otp.ftl -->
|
||||
|
||||
<#if section="header">
|
||||
${msg("doLogIn")}
|
||||
<#elseif section="form">
|
||||
<form id="kc-otp-login-form" class="${properties.kcFormClass!}" onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post">
|
||||
<input id="selectedCredentialId" type="hidden" name="selectedCredentialId" value="${otpLogin.selectedCredentialId!''}">
|
||||
<#if otpLogin.userOtpCredentials?size gt 1>
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<div class="${properties.kcInputWrapperClass!}">
|
||||
<#list otpLogin.userOtpCredentials as otpCredential>
|
||||
<div id="kc-otp-credential-${otpCredential?index}" class="${properties.kcLoginOTPListClass!}"
|
||||
onclick="toggleOTP(${otpCredential?index}, '${otpCredential.id}')">
|
||||
<span class="${properties.kcLoginOTPListItemHeaderClass!}">
|
||||
<span class="${properties.kcLoginOTPListItemIconBodyClass!}">
|
||||
<i class="${properties.kcLoginOTPListItemIconClass!}" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="${properties.kcLoginOTPListItemTitleClass!}">${otpCredential.userLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
</#list>
|
||||
</div>
|
||||
</div>
|
||||
</#if>
|
||||
|
||||
<@field.input name="otp" label=msg("loginOtpOneTime") autocomplete="one-time-code" fieldName="totp" autofocus=true />
|
||||
|
||||
<@buttons.loginButton />
|
||||
</form>
|
||||
<script>
|
||||
function toggleOTP(index, value) {
|
||||
// select the clicked OTP credential
|
||||
document.getElementById("selectedCredentialId").value = value;
|
||||
// remove selected class from all OTP credentials
|
||||
Array.from(document.getElementsByClassName("${properties.kcLoginOTPListSelectedClass!}")).map(i => i.classList.remove("${properties.kcLoginOTPListSelectedClass!}"));
|
||||
// add selected class to the clicked OTP credential
|
||||
document.getElementById("kc-otp-credential-" + index).classList.add("${properties.kcLoginOTPListSelectedClass!}");
|
||||
}
|
||||
</script>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
|
@ -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>
|
||||
<!-- template: login-password.ftl -->
|
||||
<#if section = "header">
|
||||
${msg("doLogIn")}
|
||||
<#elseif section = "form">
|
||||
<div id="kc-form">
|
||||
<div id="kc-form-wrapper">
|
||||
<form id="kc-form-login" class="${properties.kcFormClass!}" onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post">
|
||||
<@field.password name="password" label=msg("password") forgotPassword=realm.resetPasswordAllowed autofocus=true autocomplete="current-password" />
|
||||
<@buttons.loginButton />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</#if>
|
||||
|
||||
</@layout.registrationLayout>
|
|
@ -0,0 +1,180 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "password-commons.ftl" as passwordCommons>
|
||||
<@layout.registrationLayout; section>
|
||||
<!-- template: login-recovery-authn-code-config.ftl -->
|
||||
<#if section = "header">
|
||||
${msg("recovery-code-config-header")}
|
||||
<#elseif section = "form">
|
||||
<!-- warning -->
|
||||
<div class="${properties.kcRecoveryCodesWarning!}" aria-label="Warning alert">
|
||||
<div class="${properties.kcAlertIconClass!}">
|
||||
<i class="fas fa-fw fa-bell" aria-hidden="true"></i>
|
||||
</div>
|
||||
<h4 class="${properties.kcAlertTitleClass!}">
|
||||
<span class="pf-screen-reader">Warning alert:</span>
|
||||
${msg("recovery-code-config-warning-title")}
|
||||
</h4>
|
||||
<div class="${properties.kcAlertDescriptionClass!}">
|
||||
<p>${msg("recovery-code-config-warning-message")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="${properties.kcPanelClass!}">
|
||||
<div class="${properties.kcPanelMainClass!}">
|
||||
<div class="${properties.kcPanelMainBodyClass!}">
|
||||
<ol id="kc-recovery-codes-list" class="${properties.kcListClass!}" role="list">
|
||||
<#list recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesList as code>
|
||||
<li>${code[0..3]}-${code[4..7]}-${code[8..]}</li>
|
||||
</#list>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="${properties.kcRecoveryCodesActions!}">
|
||||
<button id="printRecoveryCodes" class="${properties.kcButtonLinkClass}" type="button" onclick="printRecoveryCodes()">
|
||||
<i class="fas fa-print"></i> ${msg("recovery-codes-print")}
|
||||
</button>
|
||||
<button id="downloadRecoveryCodes" class="${properties.kcButtonLinkClass}" type="button" onclick="downloadRecoveryCodes()">
|
||||
<i class="fas fa-download"></i> ${msg("recovery-codes-download")}
|
||||
</button>
|
||||
<button id="copyRecoveryCodes" class="${properties.kcButtonLinkClass}" type="button" onclick="copyRecoveryCodes()">
|
||||
<i class="fas fa-copy"></i> ${msg("recovery-codes-copy")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- confirmation checkbox -->
|
||||
<div class="${properties.kcFormOptionsClass!} pf-v5-u-mt-md">
|
||||
<input class="${properties.kcCheckInputClass!}" type="checkbox" id="kcRecoveryCodesConfirmationCheck" name="kcRecoveryCodesConfirmationCheck"
|
||||
onchange="document.getElementById('saveRecoveryAuthnCodesBtn').disabled = !this.checked;"
|
||||
/>
|
||||
<label for="kcRecoveryCodesConfirmationCheck">${msg("recovery-codes-confirmation-message")}</label>
|
||||
</div>
|
||||
|
||||
<form action="${url.loginAction}" class="${properties.kcFormGroupClass!}" id="kc-recovery-codes-settings-form" method="post">
|
||||
<input type="hidden" name="generatedRecoveryAuthnCodes" value="${recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesAsString}" />
|
||||
<input type="hidden" name="generatedAt" value="${recoveryAuthnCodesConfigBean.generatedAt?c}" />
|
||||
<input type="hidden" id="userLabel" name="userLabel" value="${msg("recovery-codes-label-default")}" />
|
||||
<@passwordCommons.logoutOtherSessions/>
|
||||
|
||||
<#if isAppInitiatedAction??>
|
||||
<input type="submit"
|
||||
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}"
|
||||
id="saveRecoveryAuthnCodesBtn" value="${msg("recovery-codes-action-complete")}"
|
||||
disabled
|
||||
/>
|
||||
<button type="submit"
|
||||
class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!} pf-m-link"
|
||||
id="cancelRecoveryAuthnCodesBtn" name="cancel-aia" value="true">${msg("recovery-codes-action-cancel")}
|
||||
</button>
|
||||
<#else>
|
||||
<input type="submit"
|
||||
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
|
||||
id="saveRecoveryAuthnCodesBtn" value="${msg("recovery-codes-action-complete")}"
|
||||
disabled
|
||||
/>
|
||||
</#if>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
/* copy recovery codes */
|
||||
function copyRecoveryCodes() {
|
||||
const tmpTextarea = document.createElement("textarea");
|
||||
tmpTextarea.innerHTML = parseRecoveryCodeList();
|
||||
document.body.appendChild(tmpTextarea);
|
||||
tmpTextarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(tmpTextarea);
|
||||
}
|
||||
|
||||
/* download recovery codes */
|
||||
function formatCurrentDateTime() {
|
||||
const dt = new Date();
|
||||
const options = {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
timeZoneName: 'short'
|
||||
};
|
||||
|
||||
return dt.toLocaleString('en-US', options);
|
||||
}
|
||||
|
||||
function parseRecoveryCodeList() {
|
||||
const recoveryCodes = document.getElementById("kc-recovery-codes-list").getElementsByTagName("li");
|
||||
let recoveryCodeList = "";
|
||||
|
||||
for (let i = 0; i < recoveryCodes.length; i++) {
|
||||
const recoveryCodeLiElement = recoveryCodes[i].innerText;
|
||||
<#noparse>
|
||||
recoveryCodeList += `${i+1}: ${recoveryCodeLiElement}\r\n`;
|
||||
</#noparse>
|
||||
}
|
||||
|
||||
return recoveryCodeList;
|
||||
}
|
||||
|
||||
function buildDownloadContent() {
|
||||
const recoveryCodeList = parseRecoveryCodeList();
|
||||
const dt = new Date();
|
||||
const options = {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
timeZoneName: 'short'
|
||||
};
|
||||
|
||||
return fileBodyContent =
|
||||
"${msg("recovery-codes-download-file-header")}\n\n" +
|
||||
recoveryCodeList + "\n" +
|
||||
"${msg("recovery-codes-download-file-description")}\n\n" +
|
||||
"${msg("recovery-codes-download-file-date")} " + formatCurrentDateTime();
|
||||
}
|
||||
|
||||
function setUpDownloadLinkAndDownload(filename, text) {
|
||||
const el = document.createElement('a');
|
||||
el.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
|
||||
el.setAttribute('download', filename);
|
||||
el.style.display = 'none';
|
||||
document.body.appendChild(el);
|
||||
el.click();
|
||||
document.body.removeChild(el);
|
||||
}
|
||||
|
||||
function downloadRecoveryCodes() {
|
||||
setUpDownloadLinkAndDownload('kc-download-recovery-codes.txt', buildDownloadContent());
|
||||
}
|
||||
|
||||
/* print recovery codes */
|
||||
function buildPrintContent() {
|
||||
const recoveryCodeListHTML = document.getElementById('kc-recovery-codes-list').parentNode.innerHTML;
|
||||
const styles =
|
||||
`@page { size: auto; margin-top: 0; }
|
||||
body { width: 480px; }
|
||||
div { font-family: monospace }
|
||||
p:first-of-type { margin-top: 48px }`;
|
||||
|
||||
return printFileContent =
|
||||
"<html><style>" + styles + "</style><body>" +
|
||||
"<title>kc-download-recovery-codes</title>" +
|
||||
"<p>${msg("recovery-codes-download-file-header")}</p>" +
|
||||
"<div>" + recoveryCodeListHTML + "</div>" +
|
||||
"<p>${msg("recovery-codes-download-file-description")}</p>" +
|
||||
"<p>${msg("recovery-codes-download-file-date")} " + formatCurrentDateTime() + "</p>" +
|
||||
"</body></html>";
|
||||
}
|
||||
|
||||
function printRecoveryCodes() {
|
||||
const w = window.open();
|
||||
w.document.write(buildPrintContent());
|
||||
w.print();
|
||||
w.close();
|
||||
}
|
||||
</script>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
|
@ -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>
|
||||
<!-- template: login-recovery-authn-code-input.ftl -->
|
||||
<#if section = "header">
|
||||
${msg("auth-recovery-code-header")}
|
||||
<#elseif section = "form">
|
||||
<form id="kc-recovery-code-login-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
|
||||
<@field.input name="recoveryCodeInput" label=msg("auth-recovery-code-prompt", recoveryAuthnCodesInputBean.codeNumber?c) autofocus=true />
|
||||
|
||||
<@buttons.loginButton />
|
||||
</form>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
|
@ -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">
|
||||
<form id="kc-reset-password-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
|
||||
<#assign label>
|
||||
<#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if>
|
||||
</#assign>
|
||||
<@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"]/>
|
||||
</@buttons.actionGroup>
|
||||
|
||||
</form>
|
||||
<#elseif section = "info" >
|
||||
<#if realm.duplicateEmailsAllowed>
|
||||
${msg("emailInstructionUsername")}
|
||||
<#else>
|
||||
${msg("emailInstruction")}
|
||||
</#if>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
|
@ -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>
|
||||
<!-- template: login-update-password.ftl -->
|
||||
<#if section = "header">
|
||||
${msg("updatePasswordTitle")}
|
||||
<#elseif section = "form">
|
||||
<form id="kc-passwd-update-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post" novalidate="novalidate">
|
||||
<@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" />
|
||||
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<@passwordCommons.logoutOtherSessions/>
|
||||
</div>
|
||||
|
||||
<@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"]/>
|
||||
</#if>
|
||||
</@buttons.actionGroup>
|
||||
</form>
|
||||
|
||||
<@validator.templates/>
|
||||
<@validator.script field="password-new"/>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
|
@ -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>
|
||||
<!-- template: login-username.ftl -->
|
||||
|
||||
<#if section = "header">
|
||||
${msg("loginAccountTitle")}
|
||||
<#elseif section = "form">
|
||||
<div id="kc-form">
|
||||
<div id="kc-form-wrapper">
|
||||
<#if realm.password>
|
||||
<form id="kc-form-login" class="${properties.kcFormClass!}" onsubmit="login.disabled = true; return true;" action="${url.loginAction}"
|
||||
method="post">
|
||||
<#if !usernameHidden??>
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<#assign label>
|
||||
<#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if>
|
||||
</#assign>
|
||||
<@field.input name="username" label=label value=login.username!'' autofocus=true autocomplete="username" />
|
||||
|
||||
<#if messagesPerField.existsError('username')>
|
||||
<span id="input-error-username" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
|
||||
${kcSanitize(messagesPerField.get('username'))?no_esc}
|
||||
</span>
|
||||
</#if>
|
||||
</div>
|
||||
</#if>
|
||||
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<#if realm.rememberMe && !usernameHidden??>
|
||||
<@field.checkbox name="rememberMe" label=msg("rememberMe") value=login.rememberMe?? />
|
||||
</#if>
|
||||
</div>
|
||||
|
||||
<@buttons.loginButton />
|
||||
</form>
|
||||
</#if>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<#elseif section = "info" >
|
||||
<#if realm.password && realm.registrationAllowed && !registrationDisabled??>
|
||||
<div id="kc-registration">
|
||||
<span>${msg("noAccount")} <a href="${url.registrationUrl}">${msg("doRegister")}</a></span>
|
||||
</div>
|
||||
</#if>
|
||||
<#elseif section = "socialProviders" >
|
||||
<#if realm.password && social.providers?? && social.providers?has_content>
|
||||
<@identityProviders.show social=social />
|
||||
</#if>
|
||||
</#if>
|
||||
|
||||
</@layout.registrationLayout>
|
51
docker/compose/keycloakserver/custom/login/login.ftl
Normal file
51
docker/compose/keycloakserver/custom/login/login.ftl
Normal file
|
@ -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>
|
||||
<!-- template: login.ftl -->
|
||||
|
||||
<#if section = "header">
|
||||
${msg("loginAccountTitle")}
|
||||
<#elseif section = "form">
|
||||
<div class="pf-v5-c-login__main-footer-band-item">
|
||||
@uni-bremen.de users:
|
||||
</div>
|
||||
|
||||
<@identityProviders.show social=social/>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="pf-v5-c-login__main-footer-band-item">
|
||||
@XXX.uni-bremen.de users or invited external guests:<br>
|
||||
<a href="https://sso.fb1.uni-bremen.de">(Register here)</a>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
<div id="kc-form">
|
||||
<div id="kc-form-wrapper">
|
||||
<form id="kc-form-login" class="${properties.kcFormClass!}" onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post" novalidate="novalidate">
|
||||
<#if !usernameHidden??>
|
||||
<#assign label>
|
||||
<#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if>
|
||||
</#assign>
|
||||
<@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>
|
||||
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<#if realm.rememberMe && !usernameHidden??>
|
||||
<@field.checkbox name="rememberMe" label=msg("rememberMe") value=login.rememberMe?? />
|
||||
</#if>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="id-hidden-input" name="credentialId" <#if auth.selectedCredential?has_content>value="${auth.selectedCredential}"</#if>/>
|
||||
<@buttons.loginButton />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</#if>
|
||||
|
||||
</@layout.registrationLayout>
|
|
@ -0,0 +1,12 @@
|
|||
<#macro logoutOtherSessions>
|
||||
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
|
||||
<div class="${properties.kcFormOptionsWrapperClass!}">
|
||||
<div class="pf-v5-c-check">
|
||||
<input class="pf-v5-c-check__input" type="checkbox" id="logout-sessions" name="logout-sessions" value="on" checked>
|
||||
<label class="pf-v5-c-check__label" for="logout-sessions">
|
||||
${msg("logoutOtherSessions")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</#macro>
|
|
@ -0,0 +1,51 @@
|
|||
<#macro templates>
|
||||
<template id="errorTemplate">
|
||||
<div class="${properties.kcFormHelperTextClass}" aria-live="polite">
|
||||
<div class="${properties.kcInputHelperTextClass}">
|
||||
<div class="${properties.kcInputHelperTextItemClass} ${properties.kcError}">
|
||||
<ul class="${properties.kcInputErrorMessageClass}">
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template id="errorItemTemplate">
|
||||
<li></li>
|
||||
</template>
|
||||
</#macro>
|
||||
|
||||
<#macro script field="">
|
||||
<script type="module">
|
||||
import { validatePassword } from "${url.resourcesPath}/js/password-policy.js";
|
||||
|
||||
const activePolicies = [
|
||||
{ name: "length", policy: { value: ${passwordPolicies.length!-1}, error: "${msg('invalidPasswordMinLengthMessage')}"} },
|
||||
{ name: "maxLength", policy: { value: ${passwordPolicies.maxLength!-1}, error: "${msg('invalidPasswordMaxLengthMessage')}"} },
|
||||
{ name: "lowerCase", policy: { value: ${passwordPolicies.lowerCase!-1}, error: "${msg('invalidPasswordMinLowerCaseCharsMessage')}"} },
|
||||
{ name: "upperCase", policy: { value: ${passwordPolicies.upperCase!-1}, error: "${msg('invalidPasswordMinUpperCaseCharsMessage')}"} },
|
||||
{ name: "digits", policy: { value: ${passwordPolicies.digits!-1}, error: "${msg('invalidPasswordMinDigitsMessage')}"} },
|
||||
{ name: "specialChars", policy: { value: ${passwordPolicies.specialChars!-1}, error: "${msg('invalidPasswordMinSpecialCharsMessage')}"} }
|
||||
].filter(p => p.policy.value !== -1);
|
||||
|
||||
document.getElementById("${field}").addEventListener("change", (event) => {
|
||||
|
||||
const errorContainer = document.getElementById("input-error-container-${field}");
|
||||
const template = document.querySelector("#errorTemplate").content.cloneNode(true);
|
||||
const errors = validatePassword(event.target.value, activePolicies);
|
||||
|
||||
if (errors.length === 0) {
|
||||
errorContainer.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
const errorList = template.querySelector("ul");
|
||||
const htmlErrors = errors.forEach((e) => {
|
||||
const row = document.querySelector("#errorItemTemplate").content.cloneNode(true);
|
||||
const li = row.querySelector("li");
|
||||
li.textContent = e;
|
||||
errorList.appendChild(li);
|
||||
});
|
||||
errorContainer.replaceChildren(template);
|
||||
});
|
||||
</script>
|
||||
</#macro>
|
|
@ -0,0 +1,27 @@
|
|||
<#macro termsAcceptance>
|
||||
<#if termsAcceptanceRequired??>
|
||||
<div class="form-group">
|
||||
<div class="${properties.kcInputWrapperClass!}">
|
||||
${msg("termsTitle")}
|
||||
<div id="kc-registration-terms-text">
|
||||
${kcSanitize(msg("termsText"))?no_esc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="${properties.kcLabelWrapperClass!}">
|
||||
<input type="checkbox" id="termsAccepted" name="termsAccepted" class="${properties.kcCheckboxInputClass!}"
|
||||
aria-invalid="<#if messagesPerField.existsError('termsAccepted')>true</#if>"
|
||||
/>
|
||||
<label for="termsAccepted" class="${properties.kcLabelClass!}">${msg("acceptTerms")}</label>
|
||||
</div>
|
||||
<#if messagesPerField.existsError('termsAccepted')>
|
||||
<div class="${properties.kcLabelWrapperClass!}">
|
||||
<span id="input-error-terms-accepted" class="${properties.kcInputErrorMessageClass!}" aria-live="polite">
|
||||
${kcSanitize(messagesPerField.get('termsAccepted'))?no_esc}
|
||||
</span>
|
||||
</div>
|
||||
</#if>
|
||||
</div>
|
||||
</#if>
|
||||
</#macro>
|
68
docker/compose/keycloakserver/custom/login/register.ftl
Normal file
68
docker/compose/keycloakserver/custom/login/register.ftl
Normal file
|
@ -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>
|
||||
<!-- template: register.ftl -->
|
||||
|
||||
<#if section = "header">
|
||||
<#if messageHeader??>
|
||||
${kcSanitize(msg("${messageHeader}"))?no_esc}
|
||||
<#else>
|
||||
${msg("registerTitle")}
|
||||
</#if>
|
||||
<#elseif section = "form">
|
||||
<form id="kc-register-form" class="${properties.kcFormClass!}" action="${url.registrationAction}" method="post" novalidate="novalidate">
|
||||
<@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" />
|
||||
</#if>
|
||||
</#if>
|
||||
</@userProfileCommons.userProfileFormFields>
|
||||
|
||||
<@registerCommons.termsAcceptance/>
|
||||
|
||||
<#if recaptchaRequired?? && (recaptchaVisible!false)>
|
||||
<div class="form-group">
|
||||
<div class="${properties.kcInputWrapperClass!}">
|
||||
<div class="g-recaptcha" data-size="compact" data-sitekey="${recaptchaSiteKey}" data-action="${recaptchaAction}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</#if>
|
||||
|
||||
<#if recaptchaRequired?? && !(recaptchaVisible!false)>
|
||||
<script>
|
||||
function onSubmitRecaptcha(token) {
|
||||
document.getElementById("kc-register-form").requestSubmit();
|
||||
}
|
||||
</script>
|
||||
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
|
||||
<button class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!} g-recaptcha"
|
||||
data-sitekey="${recaptchaSiteKey}" data-callback="onSubmitRecaptcha" data-action="${recaptchaAction}" type="submit">
|
||||
${msg("doRegister")}
|
||||
</button>
|
||||
</div>
|
||||
<#else>
|
||||
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
|
||||
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doRegister")}"/>
|
||||
</div>
|
||||
</#if>
|
||||
|
||||
<div class="${properties.kcFormGroupClass!} pf-v5-c-login__main-footer-band">
|
||||
<div id="kc-form-options" class="${properties.kcFormOptionsClass!} pf-v5-c-login__main-footer-band-item">
|
||||
<div class="${properties.kcFormOptionsWrapperClass!}">
|
||||
<span><a href="${url.loginUrl}">${kcSanitize(msg("backToLogin"))?no_esc}</a></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<@validator.templates/>
|
||||
<@validator.script field="password"/>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
|
@ -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%;
|
||||
}
|
||||
}
|
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 7.1 KiB |
|
@ -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;
|
||||
}
|
|
@ -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<HTMLElement, () => 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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<@layout.registrationLayout displayInfo=false; section>
|
||||
<!-- template: select-authenticator.ftl -->
|
||||
|
||||
<#if section = "header" || section = "show-username">
|
||||
<#if section = "header">
|
||||
${msg("loginChooseAuthenticator")}
|
||||
</#if>
|
||||
<#elseif section = "form">
|
||||
|
||||
<ul class="${properties.kcSelectAuthListClass!}" role="list">
|
||||
<#list auth.authenticationSelections as authenticationSelection>
|
||||
<li class="${properties.kcSelectAuthListItemWrapperClass!}">
|
||||
<form id="kc-select-credential-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
|
||||
<input type="hidden" name="authenticationExecution" value="${authenticationSelection.authExecId}">
|
||||
</form>
|
||||
<div class="${properties.kcSelectAuthListItemClass!}" onclick="document.forms[${authenticationSelection?index}].requestSubmit()">
|
||||
<div class="pf-v5-c-data-list__item-content">
|
||||
<div class="${properties.kcSelectAuthListItemIconClass!}">
|
||||
<i class="${properties['${authenticationSelection.iconCssClass}']!authenticationSelection.iconCssClass} ${properties.kcSelectAuthListItemIconPropertyClass!}"></i>
|
||||
</div>
|
||||
<div class="${properties.kcSelectAuthListItemBodyClass!}">
|
||||
<h2 class="${properties.kcSelectAuthListItemHeadingClass!}">
|
||||
${msg('${authenticationSelection.displayName}')}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="${properties.kcSelectAuthListItemDescriptionClass!}">
|
||||
${msg('${authenticationSelection.helpText}')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="${properties.kcSelectAuthListItemFillClass!}">
|
||||
<i class="${properties.kcSelectAuthListItemArrowIconClass!}" aria-hidden="true"></i>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</#list>
|
||||
</ul>
|
||||
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<#macro show social>
|
||||
<div id="kc-social-providers" class="marx">
|
||||
<#list social.providers as p>
|
||||
<a id="social-${p.alias}" class="${properties.kcFormSocialAccountListButtonClass!} <#if social.providers?size gt 3>${properties.kcFormSocialAccountGridItem!}</#if>" aria-label="${p.displayName}"
|
||||
type="button" href="${p.loginUrl}">
|
||||
<#if p.iconClasses?has_content>
|
||||
<span class="${p.iconClasses!}">${p.displayName!}</span>
|
||||
<#else>
|
||||
<#switch p.alias>
|
||||
<#default>
|
||||
<span aria-hidden="true">
|
||||
<svg
|
||||
version="1.1"
|
||||
id="svg2"
|
||||
xml:space="preserve"
|
||||
width="5.2600002cm"
|
||||
height="5.2600002cm"
|
||||
viewBox="0 0 39.760631 39.76063"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs6" /><g
|
||||
id="g8"
|
||||
transform="scale(1.3333333)"><g
|
||||
id="g10"><path
|
||||
d="m 8.184,15.336 c 0,3.648 3.035,6.551 6.679,6.551 3.649,0 6.684,-2.836 6.684,-6.551 v -13.5 h -3.309 v 13.5 c 0,1.824 -1.484,3.242 -3.304,3.242 -1.758,0 -3.309,-1.418 -3.309,-3.242 V 1.836 H 8.184 Z"
|
||||
style="fill:#d7193a;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path14" /><path
|
||||
d="m 24.789,1.836 v 13.5 c 0,5.469 -4.523,9.855 -9.992,9.855 -5.535,0 -9.988,-4.32 -9.988,-9.855 V 1.836 H 1.5 v 13.5 c 0,7.359 6.008,13.164 13.297,13.164 7.359,0 13.297,-5.805 13.297,-13.164 v -13.5 z"
|
||||
style="fill:#d7193a;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path16" /></g></g></svg>
|
||||
|
||||
<span class="${properties.kcFormSocialAccountNameClass!}">${p.displayName!}</span>
|
||||
</span>
|
||||
</#switch>
|
||||
</#if>
|
||||
</a>
|
||||
</#list>
|
||||
</div>
|
||||
</#macro>
|
236
docker/compose/keycloakserver/custom/login/template.ftl
Normal file
236
docker/compose/keycloakserver/custom/login/template.ftl
Normal file
|
@ -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")}</#if>
|
||||
</#assign>
|
||||
<@field.group name="username" label=label>
|
||||
<div class="${properties.kcInputGroup}">
|
||||
<div class="${properties.kcInputGroupItemClass} ${properties.kcFill}">
|
||||
<span class="${properties.kcInputClass} ${properties.kcFormReadOnlyClass}">
|
||||
<input id="kc-attempted-username" value="${auth.attemptedUsername}" readonly>
|
||||
</span>
|
||||
</div>
|
||||
<div class="${properties.kcInputGroupItemClass}">
|
||||
<button id="reset-login" class="${properties.kcFormPasswordVisibilityButtonClass} kc-login-tooltip" type="button"
|
||||
aria-label="${msg('restartLoginTooltip')}" onclick="location.href='${url.loginRestartFlowUrl}'">
|
||||
<i class="fa-sync-alt fas" aria-hidden="true"></i>
|
||||
<span class="kc-tooltip-text">${msg("restartLoginTooltip")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</@field.group>
|
||||
</#macro>
|
||||
|
||||
<#macro registrationLayout bodyClass="" displayInfo=false displayMessage=true displayRequiredFields=false>
|
||||
<!DOCTYPE html>
|
||||
<html class="${properties.kcHtmlClass!}" lang="${lang}"<#if realm.internationalizationEnabled> dir="${(locale.rtl)?then('rtl','ltr')}"</#if>>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="color-scheme" content="light${darkMode?then(' dark', '')}">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<#if properties.meta?has_content>
|
||||
<#list properties.meta?split(' ') as meta>
|
||||
<meta name="${meta?split('==')[0]}" content="${meta?split('==')[1]}"/>
|
||||
</#list>
|
||||
</#if>
|
||||
<title>${msg("loginTitle",(realm.displayName!''))}</title>
|
||||
<link rel="icon" href="${url.resourcesPath}/img/favicon.ico" />
|
||||
<#if properties.stylesCommon?has_content>
|
||||
<#list properties.stylesCommon?split(' ') as style>
|
||||
<link href="${url.resourcesCommonPath}/${style}" rel="stylesheet" />
|
||||
</#list>
|
||||
</#if>
|
||||
<#if properties.styles?has_content>
|
||||
<#list properties.styles?split(' ') as style>
|
||||
<link href="${url.resourcesPath}/${style}" rel="stylesheet" />
|
||||
</#list>
|
||||
</#if>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"rfc4648": "${url.resourcesCommonPath}/vendor/rfc4648/rfc4648.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<#if darkMode>
|
||||
<script type="module" async blocking="render">
|
||||
const DARK_MODE_CLASS = "${properties.kcDarkModeClass}";
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
updateDarkMode(mediaQuery.matches);
|
||||
mediaQuery.addEventListener("change", (event) => updateDarkMode(event.matches));
|
||||
|
||||
function updateDarkMode(isEnabled) {
|
||||
const { classList } = document.documentElement;
|
||||
|
||||
if (isEnabled) {
|
||||
classList.add(DARK_MODE_CLASS);
|
||||
} else {
|
||||
classList.remove(DARK_MODE_CLASS);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</#if>
|
||||
<#if properties.scripts?has_content>
|
||||
<#list properties.scripts?split(' ') as script>
|
||||
<script src="${url.resourcesPath}/${script}" type="text/javascript"></script>
|
||||
</#list>
|
||||
</#if>
|
||||
<#if scripts??>
|
||||
<#list scripts as script>
|
||||
<script src="${script}" type="text/javascript"></script>
|
||||
</#list>
|
||||
</#if>
|
||||
<script type="module" src="${url.resourcesPath}/js/passwordVisibility.js"></script>
|
||||
<script type="module">
|
||||
import { startSessionPolling } from "${url.resourcesPath}/js/authChecker.js";
|
||||
|
||||
startSessionPolling(
|
||||
"${url.ssoLoginInOtherTabsUrl?no_esc}"
|
||||
);
|
||||
</script>
|
||||
<#if authenticationSession??>
|
||||
<script type="module">
|
||||
import { checkAuthSession } from "${url.resourcesPath}/js/authChecker.js";
|
||||
|
||||
checkAuthSession(
|
||||
"${authenticationSession.authSessionIdHash}"
|
||||
);
|
||||
</script>
|
||||
</#if>
|
||||
<script>
|
||||
// Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1404468
|
||||
const isFirefox = true;
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body id="keycloak-bg" class="${properties.kcBodyClass!}">
|
||||
<div class="${properties.kcLogin!}">
|
||||
<div class="${properties.kcLoginContainer!}">
|
||||
<header id="kc-header" class="pf-v5-c-login__header">
|
||||
<div id="kc-header-wrapper"
|
||||
class="pf-v5-c-brand">${kcSanitize(msg("loginTitleHtml",(realm.displayNameHtml!'')))?no_esc}</div>
|
||||
</header>
|
||||
<main class="${properties.kcLoginMain!}">
|
||||
<div class="${properties.kcLoginMainHeader!}">
|
||||
<h1 class="${properties.kcLoginMainTitle!}" id="kc-page-title"><#nested "header"></h1>
|
||||
<#if realm.internationalizationEnabled && locale.supported?size gt 1>
|
||||
<div class="${properties.kcLoginMainHeaderUtilities!}">
|
||||
<div class="${properties.kcInputClass!}">
|
||||
<select
|
||||
aria-label="${msg("languages")}"
|
||||
id="login-select-toggle"
|
||||
onchange="if (this.value) window.location.href=this.value"
|
||||
>
|
||||
<#list locale.supported?sort_by("label") as l>
|
||||
<option
|
||||
value="${l.url}"
|
||||
${(l.languageTag == locale.currentLanguageTag)?then('selected','')}
|
||||
>
|
||||
${l.label}
|
||||
</option>
|
||||
</#list>
|
||||
</select>
|
||||
<span class="${properties.kcFormControlUtilClass}">
|
||||
<span class="${properties.kcFormControlToggleIcon!}">
|
||||
<svg
|
||||
class="pf-v5-svg"
|
||||
viewBox="0 0 320 512"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
width="1em"
|
||||
height="1em"
|
||||
>
|
||||
<path
|
||||
d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</#if>
|
||||
</div>
|
||||
<div class="${properties.kcLoginMainBody!}">
|
||||
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
|
||||
<#if displayRequiredFields>
|
||||
<div class="${properties.kcContentWrapperClass!}">
|
||||
<div class="${properties.kcLabelWrapperClass!} subtitle">
|
||||
<span class="${properties.kcInputHelperTextItemTextClass!}">
|
||||
<span class="${properties.kcInputRequiredClass!}">*</span> ${msg("requiredFields")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</#if>
|
||||
<#else>
|
||||
<#if displayRequiredFields>
|
||||
<div class="${properties.kcContentWrapperClass!}">
|
||||
<div class="${properties.kcLabelWrapperClass!} subtitle">
|
||||
<span class="${properties.kcInputHelperTextItemTextClass!}">
|
||||
<span class="${properties.kcInputRequiredClass!}">*</span> ${msg("requiredFields")}
|
||||
</span>
|
||||
</div>
|
||||
<div class="${properties.kcFormClass} ${properties.kcContentWrapperClass}">
|
||||
<#nested "show-username">
|
||||
<@username />
|
||||
</div>
|
||||
</div>
|
||||
<#else>
|
||||
<div class="${properties.kcFormClass} ${properties.kcContentWrapperClass}">
|
||||
<#nested "show-username">
|
||||
<@username />
|
||||
</div>
|
||||
</#if>
|
||||
</#if>
|
||||
|
||||
<#-- 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??)>
|
||||
<div class="${properties.kcAlertClass!} pf-m-${(message.type = 'error')?then('danger', message.type)}">
|
||||
<div class="${properties.kcAlertIconClass!}">
|
||||
<#if message.type = 'success'><span class="${properties.kcFeedbackSuccessIcon!}"></span></#if>
|
||||
<#if message.type = 'warning'><span class="${properties.kcFeedbackWarningIcon!}"></span></#if>
|
||||
<#if message.type = 'error'><span class="${properties.kcFeedbackErrorIcon!}"></span></#if>
|
||||
<#if message.type = 'info'><span class="${properties.kcFeedbackInfoIcon!}"></span></#if>
|
||||
</div>
|
||||
<span class="${properties.kcAlertTitleClass!} kc-feedback-text">${kcSanitize(message.summary)?no_esc}</span>
|
||||
</div>
|
||||
</#if>
|
||||
|
||||
<#nested "form">
|
||||
|
||||
<#if auth?has_content && auth.showTryAnotherWayLink()>
|
||||
<form id="kc-select-try-another-way-form" action="${url.loginAction}" method="post" novalidate="novalidate">
|
||||
<input type="hidden" name="tryAnotherWay" value="on"/>
|
||||
<a id="try-another-way" href="javascript:document.forms['kc-select-try-another-way-form'].requestSubmit()"
|
||||
class="${properties.kcButtonSecondaryClass} ${properties.kcButtonBlockClass} ${properties.kcMarginTopClass}">
|
||||
${kcSanitize(msg("doTryAnotherWay"))?no_esc}
|
||||
</a>
|
||||
</form>
|
||||
</#if>
|
||||
|
||||
<#if displayInfo>
|
||||
<div id="kc-info" class="${properties.kcSignUpClass!}">
|
||||
<div id="kc-info-wrapper" class="${properties.kcInfoAreaWrapperClass!}">
|
||||
<#nested "info">
|
||||
</div>
|
||||
</div>
|
||||
</#if>
|
||||
</div>
|
||||
<div class="pf-v5-c-login__main-footer">
|
||||
<#nested "socialProviders">
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<@loginFooter.content/>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</#macro>
|
21
docker/compose/keycloakserver/custom/login/terms.ftl
Normal file
21
docker/compose/keycloakserver/custom/login/terms.ftl
Normal file
|
@ -0,0 +1,21 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<#import "buttons.ftl" as buttons>
|
||||
|
||||
<@layout.registrationLayout displayMessage=false; section>
|
||||
<!-- template: terms.ftl -->
|
||||
|
||||
<#if section = "header">
|
||||
${msg("termsTitle")}
|
||||
<#elseif section = "form">
|
||||
<div class="${properties.kcContentWrapperClass}">
|
||||
${kcSanitize(msg("termsText"))?no_esc}
|
||||
</div>
|
||||
<form class="${properties.kcFormClass!}" action="${url.loginAction}" method="POST">
|
||||
<@buttons.actionGroup>
|
||||
<@buttons.button name="accept" id="kc-accept" label="doAccept" class=["kcButtonPrimaryClass"]/>
|
||||
<@buttons.button name="cancel" id="kc-decline" label="doDecline" class=["kcButtonSecondaryClass"]/>
|
||||
</@buttons.actionGroup>
|
||||
</form>
|
||||
<div class="clearfix"></div>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
110
docker/compose/keycloakserver/custom/login/theme.properties
Normal file
110
docker/compose/keycloakserver/custom/login/theme.properties
Normal file
|
@ -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=
|
|
@ -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 != "">
|
||||
<div class="${properties.kcFormGroupClass!}"
|
||||
<#list group.html5DataAnnotations as key, value>
|
||||
data-${key}="${value}"
|
||||
</#list>
|
||||
>
|
||||
|
||||
<#assign groupDisplayHeader=group.displayHeader!"">
|
||||
<#if groupDisplayHeader != "">
|
||||
<#assign groupHeaderText=advancedMsg(groupDisplayHeader)!group>
|
||||
<#else>
|
||||
<#assign groupHeaderText=group.name!"">
|
||||
</#if>
|
||||
<div class="${properties.kcContentWrapperClass!}">
|
||||
<label id="header-${attribute.group.name}" class="${kcFormGroupHeader!}">${groupHeaderText}</label>
|
||||
</div>
|
||||
|
||||
<#assign groupDisplayDescription=group.displayDescription!"">
|
||||
<#if groupDisplayDescription != "">
|
||||
<#assign groupDescriptionText=advancedMsg(groupDisplayDescription)!"">
|
||||
<div class="${properties.kcLabelWrapperClass!}">
|
||||
<label id="description-${group.name}" class="${properties.kcLabelClass!}">${groupDescriptionText}</label>
|
||||
</div>
|
||||
</#if>
|
||||
</div>
|
||||
</#if>
|
||||
</#if>
|
||||
|
||||
<#nested "beforeField" attribute>
|
||||
|
||||
<@field.group name=attribute.name label=advancedMsg(attribute.displayName!'') error=kcSanitize(messagesPerField.get('${attribute.name}'))?no_esc required=attribute.required>
|
||||
<div class="${properties.kcInputWrapperClass!}">
|
||||
<#if attribute.annotations.inputHelperTextBefore??>
|
||||
<div class="${properties.kcInputHelperTextBeforeClass!}" id="form-help-text-before-${attribute.name}" aria-live="polite">${kcSanitize(advancedMsg(attribute.annotations.inputHelperTextBefore))?no_esc}</div>
|
||||
</#if>
|
||||
<@inputFieldByType attribute=attribute/>
|
||||
<#if attribute.annotations.inputHelperTextAfter??>
|
||||
<div class="${properties.kcInputHelperTextAfterClass!}" id="form-help-text-after-${attribute.name}" aria-live="polite">${kcSanitize(advancedMsg(attribute.annotations.inputHelperTextAfter))?no_esc}</div>
|
||||
</#if>
|
||||
</div>
|
||||
</@field.group>
|
||||
<#nested "afterField" attribute>
|
||||
</#list>
|
||||
|
||||
<#list profile.html5DataAnnotations?keys as key>
|
||||
<script type="module" src="${url.resourcesPath}/js/${key}.js"></script>
|
||||
</#list>
|
||||
</#macro>
|
||||
|
||||
<#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!''/>
|
||||
</#list>
|
||||
<#else>
|
||||
<@inputTag attribute=attribute value=attribute.value!''/>
|
||||
</#if>
|
||||
</#switch>
|
||||
</#macro>
|
||||
|
||||
<#macro inputTag attribute value>
|
||||
<span class="${properties.kcInputClass} <#if error?has_content>${properties.kcError}</#if>">
|
||||
<input type="<@inputTagType attribute=attribute/>" id="${attribute.name}" name="${attribute.name}" value="${(value!'')}" class="${properties.kcInputClass!}"
|
||||
aria-invalid="<#if messagesPerField.existsError('${attribute.name}')>true</#if>"
|
||||
<#if attribute.readOnly>disabled</#if>
|
||||
<#if attribute.autocomplete??>autocomplete="${attribute.autocomplete}"</#if>
|
||||
<#if attribute.annotations.inputTypePlaceholder??>placeholder="${advancedMsg(attribute.annotations.inputTypePlaceholder)}"</#if>
|
||||
<#if attribute.annotations.inputTypePattern??>pattern="${attribute.annotations.inputTypePattern}"</#if>
|
||||
<#if attribute.annotations.inputTypeSize??>size="${attribute.annotations.inputTypeSize}"</#if>
|
||||
<#if attribute.annotations.inputTypeMaxlength??>maxlength="${attribute.annotations.inputTypeMaxlength}"</#if>
|
||||
<#if attribute.annotations.inputTypeMinlength??>minlength="${attribute.annotations.inputTypeMinlength}"</#if>
|
||||
<#if attribute.annotations.inputTypeMax??>max="${attribute.annotations.inputTypeMax}"</#if>
|
||||
<#if attribute.annotations.inputTypeMin??>min="${attribute.annotations.inputTypeMin}"</#if>
|
||||
<#if attribute.annotations.inputTypeStep??>step="${attribute.annotations.inputTypeStep}"</#if>
|
||||
<#list attribute.html5DataAnnotations as key, value>
|
||||
data-${key}="${value}"
|
||||
</#list>
|
||||
/>
|
||||
</span>
|
||||
</#macro>
|
||||
|
||||
<#macro inputTagType attribute>
|
||||
<#compress>
|
||||
<#if attribute.annotations.inputType??>
|
||||
<#if attribute.annotations.inputType?starts_with("html5-")>
|
||||
${attribute.annotations.inputType[6..]}
|
||||
<#else>
|
||||
${attribute.annotations.inputType}
|
||||
</#if>
|
||||
<#else>
|
||||
text
|
||||
</#if>
|
||||
</#compress>
|
||||
</#macro>
|
||||
|
||||
<#macro textareaTag attribute>
|
||||
<textarea id="${attribute.name}" name="${attribute.name}" class="${properties.kcInputClass!}"
|
||||
aria-invalid="<#if messagesPerField.existsError('${attribute.name}')>true</#if>"
|
||||
<#if attribute.readOnly>disabled</#if>
|
||||
<#if attribute.annotations.inputTypeCols??>cols="${attribute.annotations.inputTypeCols}"</#if>
|
||||
<#if attribute.annotations.inputTypeRows??>rows="${attribute.annotations.inputTypeRows}"</#if>
|
||||
<#if attribute.annotations.inputTypeMaxlength??>maxlength="${attribute.annotations.inputTypeMaxlength}"</#if>
|
||||
>${(attribute.value!'')}</textarea>
|
||||
</#macro>
|
||||
|
||||
<#macro selectTag attribute>
|
||||
<div class="${properties.kcInputClass!}">
|
||||
<select id="${attribute.name}" name="${attribute.name}"
|
||||
aria-invalid="<#if messagesPerField.existsError('${attribute.name}')>true</#if>"
|
||||
<#if attribute.readOnly>disabled</#if>
|
||||
<#if attribute.annotations.inputType=='multiselect'>multiple</#if>
|
||||
<#if attribute.annotations.inputTypeSize??>size="${attribute.annotations.inputTypeSize}"</#if>
|
||||
>
|
||||
<#if attribute.annotations.inputType=='select'>
|
||||
<option value=""></option>
|
||||
</#if>
|
||||
|
||||
<#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=[]>
|
||||
</#if>
|
||||
|
||||
<#list options as option>
|
||||
<option value="${option}" <#if attribute.values?seq_contains(option)>selected</#if>><@selectOptionLabelText attribute=attribute option=option/></option>
|
||||
</#list>
|
||||
</select>
|
||||
<span class="${properties.kcFormControlUtilClass}">
|
||||
<span class="${properties.kcFormControlToggleIcon!}">
|
||||
<svg
|
||||
class="pf-v5-svg"
|
||||
viewBox="0 0 320 512"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
width="1em"
|
||||
height="1em"
|
||||
>
|
||||
<path
|
||||
d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</#macro>
|
||||
|
||||
<#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>
|
||||
<input type="hidden" id="${attribute.name}-empty" name="${attribute.name}" value=""/>
|
||||
<#assign inputType='checkbox'>
|
||||
<#assign classDiv=properties.kcInputClassCheckbox!>
|
||||
<#assign classInput=properties.kcInputClassCheckboxInput!>
|
||||
<#assign classLabel=properties.kcInputClassCheckboxLabel!>
|
||||
</#if>
|
||||
|
||||
<#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=[]>
|
||||
</#if>
|
||||
|
||||
<#list options as option>
|
||||
<div class="${classDiv}">
|
||||
<input type="${inputType}" id="${attribute.name}-${option}" name="${attribute.name}" value="${option}" class="${classInput}"
|
||||
aria-invalid="<#if messagesPerField.existsError('${attribute.name}')>true</#if>"
|
||||
<#if attribute.readOnly>disabled</#if>
|
||||
<#if attribute.values?seq_contains(option)>checked</#if>
|
||||
/>
|
||||
<label for="${attribute.name}-${option}" class="${classLabel}<#if attribute.readOnly> ${properties.kcInputClassRadioCheckboxLabelDisabled!}</#if>"><@selectOptionLabelText attribute=attribute option=option/></label>
|
||||
</div>
|
||||
</#list>
|
||||
</#macro>
|
||||
|
||||
<#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}
|
||||
</#if>
|
||||
</#if>
|
||||
</#compress>
|
||||
</#macro>
|
|
@ -0,0 +1,118 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<@layout.registrationLayout displayInfo=(realm.registrationAllowed && !registrationDisabled??); section>
|
||||
<!-- template: webauthn-autthenticate.ftl -->
|
||||
|
||||
<#if section = "title">
|
||||
title
|
||||
<#elseif section = "header">
|
||||
${kcSanitize(msg("webauthn-login-title"))?no_esc}
|
||||
<#elseif section = "form">
|
||||
<div id="kc-form-webauthn" class="${properties.kcFormClass!}">
|
||||
<form id="webauth" action="${url.loginAction}" method="post">
|
||||
<input type="hidden" id="clientDataJSON" name="clientDataJSON"/>
|
||||
<input type="hidden" id="authenticatorData" name="authenticatorData"/>
|
||||
<input type="hidden" id="signature" name="signature"/>
|
||||
<input type="hidden" id="credentialId" name="credentialId"/>
|
||||
<input type="hidden" id="userHandle" name="userHandle"/>
|
||||
<input type="hidden" id="error" name="error"/>
|
||||
</form>
|
||||
|
||||
<div class="${properties.kcFormGroupClass!} no-bottom-margin">
|
||||
<#if authenticators??>
|
||||
<form id="authn_select" class="${properties.kcFormClass!}">
|
||||
<#list authenticators.authenticators as authenticator>
|
||||
<input type="hidden" name="authn_use_chk" value="${authenticator.credentialId}"/>
|
||||
</#list>
|
||||
</form>
|
||||
|
||||
<#if shouldDisplayAuthenticators?? && shouldDisplayAuthenticators>
|
||||
<#if authenticators.authenticators?size gt 1>
|
||||
<p class="${properties.kcSelectAuthListItemTitle!}">${kcSanitize(msg("webauthn-available-authenticators"))?no_esc}</p>
|
||||
</#if>
|
||||
|
||||
<ul class="${properties.kcSelectAuthListClass!}" role="list">
|
||||
<#list authenticators.authenticators as authenticator>
|
||||
<li class="${properties.kcSelectAuthListItemWrapperClass!}">
|
||||
<div id="kc-webauthn-authenticator-item-${authenticator?index}" class="${properties.kcSelectAuthListItemClass!}">
|
||||
<div class="${properties.kcSelectAuthListItemIconClass!}">
|
||||
<div class="${properties.kcWebAuthnDefaultIcon!}">
|
||||
<#switch authenticator.transports.iconClass>
|
||||
<#case "kcWebAuthnBLE">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 217.499 217.499" xml:space="preserve"><path d="m123.264 108.749 45.597-44.488a8.997 8.997 0 0 0 0-12.882l-50.038-48.82A9 9 0 0 0 103.538 9v80.504l-42.331-41.3a9 9 0 1 0-12.57 12.883l48.851 47.663-48.851 47.663a9 9 0 1 0 12.57 12.883l42.331-41.3V208.5a9 9 0 0 0 15.285 6.441l50.038-48.82a8.997 8.997 0 0 0 0-12.882l-45.597-44.49zm-1.725-78.395 28.15 27.465-28.15 27.465v-54.93zm0 156.789v-54.93l28.15 27.465-28.15 27.465z" fill="currentColor"/></svg>
|
||||
<#break>
|
||||
<#case "kcWebAuthnNFC">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 19a1 1 0 1 0 0 2v-2Zm.01 2a1 1 0 1 0 0-2v2Zm2.68-3.96a1 1 0 0 0 1.347-1.48l-1.346 1.48Zm3.364-3.7a1 1 0 0 0 1.346-1.48l-1.346 1.48Zm-10.09 2.22a1 1 0 0 0 1.346 1.48l-1.346-1.48ZM4.6 11.86a1 1 0 1 0 1.345 1.48l-1.345-1.48ZM12 21h.01v-2H12v2Zm0-5c1.036 0 1.979.393 2.69 1.04l1.345-1.48A5.982 5.982 0 0 0 12 14v2Zm0-5c2.331 0 4.454.886 6.053 2.34l1.346-1.48A10.964 10.964 0 0 0 12 9v2ZM9.31 17.04A3.982 3.982 0 0 1 12 16v-2a5.982 5.982 0 0 0-4.036 1.56l1.346 1.48Zm-3.364-3.7A8.964 8.964 0 0 1 12 11V9a10.964 10.964 0 0 0-7.4 2.86l1.346 1.48Z" fill="currentColor"/></svg>
|
||||
<#break>
|
||||
<#case "kcWebAuthnUSB">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 1.25a.75.75 0 0 1 .624.334l2 3a.75.75 0 1 1-1.248.832l-.626-.939v10.515c.121-.062.248-.115.38-.16l3.265-1.088c.51-.17.855-.647.855-1.185v-1.85a1.195 1.195 0 0 1-.634-.325 1.239 1.239 0 0 1-.341-.735 4.845 4.845 0 0 1-.025-.615v-.068c0-.206 0-.427.025-.615.03-.219.105-.5.341-.735.236-.236.516-.311.735-.341.188-.025.41-.025.615-.025h.069c.205 0 .426 0 .614.025.219.03.5.105.735.341.236.236.311.516.341.735.025.188.025.41.025.615v.068c0 .206 0 .427-.025.615-.03.219-.105.5-.341.735-.2.2-.434.285-.634.324v1.85a2.75 2.75 0 0 1-1.88 2.61l-3.265 1.088a1.25 1.25 0 0 0-.855 1.186v.703a2 2 0 1 1-1.5 0v-3.704a1.25 1.25 0 0 0-.855-1.185L7.13 12.167a2.75 2.75 0 0 1-1.88-2.609V7.582a1.75 1.75 0 1 1 1.5 0v1.976c0 .539.344 1.016.855 1.186l3.265 1.089c.132.044.259.097.38.159V4.477l-.626.939a.75.75 0 1 1-1.248-.832l2-3A.75.75 0 0 1 12 1.25Z" fill="currentColor"/></svg>
|
||||
<#break>
|
||||
<#default>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M336 352a176 176 0 1 0-167.7-122.3L7 391a24 24 0 0 0-7 17v80a24 24 0 0 0 24 24h80a24 24 0 0 0 24-24v-40h40a24 24 0 0 0 24-24v-40h40a24 24 0 0 0 17-7l33.3-33.3c16.9 5.4 35 8.3 53.7 8.3zm40-256a40 40 0 1 1 0 80 40 40 0 1 1 0-80z" fill="currentColor"/></svg>
|
||||
<#break>
|
||||
</#switch>
|
||||
</div>
|
||||
</div>
|
||||
<div class="${properties.kcSelectAuthListItemBodyClass!}">
|
||||
<div id="kc-webauthn-authenticator-label-${authenticator?index}"
|
||||
class="${properties.kcSelectAuthListItemHeadingClass!}">
|
||||
${kcSanitize(msg('${authenticator.label}'))?no_esc}
|
||||
</div>
|
||||
|
||||
<#if authenticator.transports?? && authenticator.transports.displayNameProperties?has_content>
|
||||
<div id="kc-webauthn-authenticator-transport-${authenticator?index}">
|
||||
<#list authenticator.transports.displayNameProperties as nameProperty>
|
||||
<span>${kcSanitize(msg('${nameProperty!}'))?no_esc}</span>
|
||||
<#if nameProperty?has_next>
|
||||
<span>, </span>
|
||||
</#if>
|
||||
</#list>
|
||||
</div>
|
||||
</#if>
|
||||
|
||||
<span id="kc-webauthn-authenticator-createdlabel-${authenticator?index}">
|
||||
<i>${kcSanitize(msg('webauthn-createdAt-label'))?no_esc}</i>
|
||||
</span>
|
||||
<span id="kc-webauthn-authenticator-created-${authenticator?index}">
|
||||
<i>${kcSanitize(authenticator.createdAt)?no_esc}</i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="${properties.kcSelectAuthListItemFillClass!}"></div>
|
||||
</div>
|
||||
</li>
|
||||
</#list>
|
||||
</div>
|
||||
</#if>
|
||||
</#if>
|
||||
|
||||
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
|
||||
<input id="authenticateWebAuthnButton" type="button" autofocus="autofocus"
|
||||
value="${kcSanitize(msg("webauthn-doAuthenticate"))}"
|
||||
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { authenticateByWebAuthn } from "${url.resourcesPath}/js/webauthnAuthenticate.js";
|
||||
const authButton = document.getElementById('authenticateWebAuthnButton');
|
||||
authButton.addEventListener("click", function() {
|
||||
const input = {
|
||||
isUserIdentified : ${isUserIdentified},
|
||||
challenge : '${challenge}',
|
||||
userVerification : '${userVerification}',
|
||||
rpId : '${rpId}',
|
||||
createTimeout : ${createTimeout},
|
||||
errmsg : "${msg("webauthn-unsupported-browser-text")?no_esc}"
|
||||
};
|
||||
authenticateByWebAuthn(input);
|
||||
});
|
||||
</script>
|
||||
|
||||
<#elseif section = "info">
|
||||
<#if realm.registrationAllowed && !registrationDisabled??>
|
||||
<div id="kc-registration">
|
||||
<span>${msg("noAccount")} <a href="${url.registrationUrl}">${msg("doRegister")}</a></span>
|
||||
</div>
|
||||
</#if>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
|
@ -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">
|
||||
<span class="${properties.kcWebAuthnKeyIcon!}"></span>
|
||||
${kcSanitize(msg("webauthn-registration-title"))?no_esc}
|
||||
<#elseif section = "form">
|
||||
|
||||
<form id="register" action="${url.loginAction}" method="post">
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<input type="hidden" id="clientDataJSON" name="clientDataJSON"/>
|
||||
<input type="hidden" id="attestationObject" name="attestationObject"/>
|
||||
<input type="hidden" id="publicKeyCredentialId" name="publicKeyCredentialId"/>
|
||||
<input type="hidden" id="authenticatorLabel" name="authenticatorLabel"/>
|
||||
<input type="hidden" id="transports" name="transports"/>
|
||||
<input type="hidden" id="error" name="error"/>
|
||||
<@passwordCommons.logoutOtherSessions/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script type="module">
|
||||
import { registerByWebAuthn } from "${url.resourcesPath}/js/webauthnRegister.js";
|
||||
const registerButton = document.getElementById('registerWebAuthn');
|
||||
registerButton.addEventListener("click", function() {
|
||||
const input = {
|
||||
challenge : '${challenge}',
|
||||
userid : '${userid}',
|
||||
username : '${username}',
|
||||
signatureAlgorithms : [<#list signatureAlgorithms as sigAlg>${sigAlg?c},</#list>],
|
||||
rpEntityName : '${rpEntityName}',
|
||||
rpId : '${rpId}',
|
||||
attestationConveyancePreference : '${attestationConveyancePreference}',
|
||||
authenticatorAttachment : '${authenticatorAttachment}',
|
||||
requireResidentKey : '${requireResidentKey}',
|
||||
userVerificationRequirement : '${userVerificationRequirement}',
|
||||
createTimeout : ${createTimeout},
|
||||
excludeCredentialIds : '${excludeCredentialIds}',
|
||||
initLabel : "${msg("webauthn-registration-init-label")?no_esc}",
|
||||
initLabelPrompt : "${msg("webauthn-registration-init-label-prompt")?no_esc}",
|
||||
errmsg : "${msg("webauthn-unsupported-browser-text")?no_esc}"
|
||||
};
|
||||
registerByWebAuthn(input);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="pf-v5-u-py-lg ${properties.kcFormClass!}">
|
||||
<@buttons.actionGroup>
|
||||
<@buttons.button id="registerWebAuthn" label="doRegisterSecurityKey" />
|
||||
<#if !isSetRetry?has_content && isAppInitiatedAction?has_content>
|
||||
<form action="${url.loginAction}" id="kc-webauthn-settings-form" method="post">
|
||||
<@buttons.button id="cancelWebAuthnAIA" name="cancel-aia" label="doCancel" class=["kcButtonSecondaryClass"]/>
|
||||
</form>
|
||||
</#if>
|
||||
</@buttons.actionGroup>
|
||||
</div>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
2
docker/compose/logs_all.sh
Normal file
2
docker/compose/logs_all.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker compose logs -f
|
||||
|
2
docker/compose/logs_keycloakpostgres.sh
Normal file
2
docker/compose/logs_keycloakpostgres.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker compose logs -f keycloakpostgres
|
||||
|
2
docker/compose/logs_keycloakserver.sh
Normal file
2
docker/compose/logs_keycloakserver.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker compose logs -f keycloakserver
|
||||
|
2
docker/compose/logs_nginx.sh
Normal file
2
docker/compose/logs_nginx.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker compose logs -f nginx
|
||||
|
30
docker/compose/nginx/compose.yaml
Normal file
30
docker/compose/nginx/compose.yaml
Normal file
|
@ -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
|
||||
|
54
docker/compose/nginx/nginx.conf
Normal file
54
docker/compose/nginx/nginx.conf
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
23
docker/compose/register/Dockerfile
Normal file
23
docker/compose/register/Dockerfile
Normal file
|
@ -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"]
|
||||
|
29
docker/compose/register/compose.yaml
Normal file
29
docker/compose/register/compose.yaml
Normal file
|
@ -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
|
||||
|
||||
|
74
docker/compose/register/data/add_user.py
Normal file
74
docker/compose/register/data/add_user.py
Normal file
|
@ -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, ""
|
||||
|
6
docker/compose/register/data/allowed_domains.json
Normal file
6
docker/compose/register/data/allowed_domains.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"allowed_domains": [
|
||||
"uni-bremen.de", "marum.de", "awi.de"
|
||||
]
|
||||
}
|
||||
|
6
docker/compose/register/data/blocked_users.json
Normal file
6
docker/compose/register/data/blocked_users.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"blocked_users": [
|
||||
""
|
||||
]
|
||||
}
|
||||
|
9
docker/compose/register/data/config.json
Normal file
9
docker/compose/register/data/config.json
Normal file
|
@ -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"
|
||||
}
|
||||
|
65
docker/compose/register/data/main.py
Normal file
65
docker/compose/register/data/main.py
Normal file
|
@ -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"<h1>Failure :-(</h1> We couldn't register your email {email}. <br> <h2>Reason:</h2>{error_string} <p><a href=\"https://sso.fb1.uni-bremen.de/\">back to the register form...</a>"
|
||||
else:
|
||||
return "<h1>Failure :-(</h1> There was a problem with solving the captcha. Try again. Sorry! <p><a href=\"https://sso.fb1.uni-bremen.de/\">back to the register form...</a>"
|
||||
|
||||
|
||||
@app.route("/static/<path:path>", methods=["GET"])
|
||||
def serve_static_files(path) -> Response:
|
||||
return send_from_directory("static", path)
|
||||
|
43
docker/compose/register/data/process_emails.py
Normal file
43
docker/compose/register/data/process_emails.py
Normal file
|
@ -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)
|
||||
|
2
docker/compose/register/data/run.sh
Normal file
2
docker/compose/register/data/run.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
gunicorn wsgi:app --bind 0.0.0.0:80
|
||||
|
4
docker/compose/register/data/secret_key.json
Normal file
4
docker/compose/register/data/secret_key.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"secret_key": "REDACTED"
|
||||
}
|
||||
|
50
docker/compose/register/data/static/UNI_Logo.svg
Normal file
50
docker/compose/register/data/static/UNI_Logo.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 7.1 KiB |
117
docker/compose/register/data/templates/post.html
Normal file
117
docker/compose/register/data/templates/post.html
Normal file
|
@ -0,0 +1,117 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Register your FB1 account</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input[type="email"] {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 150px;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.status-marker {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img src="/static/UNI_Logo.svg" alt="University of Bremen Logo" style="max-width: 200px;">
|
||||
</header>
|
||||
|
||||
<h1>Register your FB1 account</h1>
|
||||
This is the FB1 SSO server for
|
||||
<p>
|
||||
<table>
|
||||
<tr><td><a href="https://git.fb1.uni-bremen.de/">FB1 git/forgejo server</a></td></tr>
|
||||
<tr><td><a href="https://overleaf.fb1.uni-bremen.de/">FB1 overleaf server</a></td></tr>
|
||||
<tr><td><a href="https://overleaf.pip.uni-bremen.de/">PIP overleaf Server</a></td></tr>
|
||||
</table>
|
||||
|
||||
<h2>Who can register?</h2>
|
||||
You don't want to use your @uni-bremen.de account and have a
|
||||
<p>
|
||||
<table>
|
||||
<tr><td>@XXX.uni-bremen.de</td></tr>
|
||||
<tr><td>@marum.de</td></tr>
|
||||
<tr><td>@awi.de</td></tr>
|
||||
</table>
|
||||
<br>
|
||||
email account?<p>
|
||||
|
||||
In this case, your are at the right place.<p>
|
||||
Use this form and afterwards set your password via the "Forgot Password?" option during the login process.<p>
|
||||
|
||||
<form method="POST" id="demo-form">
|
||||
<div class="form-group">
|
||||
<label for="email">Email:</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
<p>
|
||||
<div class="form-group">
|
||||
<label for="captcha">CAPTCHA:</label>
|
||||
<input type="text" id="captcha" name="captcha" maxlength="6" size="6" required>
|
||||
</div>
|
||||
Please enter the following six letters: <font color="green"><b>AAAAAA</b></font>
|
||||
<p>
|
||||
<button type="submit" class="submit-btn">Register</button>
|
||||
</form>
|
||||
|
||||
<h2>You want to register an external guest?</h2>
|
||||
|
||||
If you have already your own account here, you can add research partners by going here:<p>
|
||||
<a href="https://sso.fb1.uni-bremen.de/externals">Register an external guest</a>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
5
docker/compose/register/data/wsgi.py
Normal file
5
docker/compose/register/data/wsgi.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from main import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
|
2
docker/compose/register/down.sh
Normal file
2
docker/compose/register/down.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker compose down
|
||||
|
2
docker/compose/register/logs.sh
Normal file
2
docker/compose/register/logs.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker compose logs -f
|
||||
|
2
docker/compose/register/make_image.sh
Normal file
2
docker/compose/register/make_image.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker build --network host -t register_image .
|
||||
|
3
docker/compose/register/up.sh
Normal file
3
docker/compose/register/up.sh
Normal file
|
@ -0,0 +1,3 @@
|
|||
docker compose down
|
||||
docker compose up -d
|
||||
|
13
docker/compose/up.sh
Normal file
13
docker/compose/up.sh
Normal file
|
@ -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
|
||||
|
2
docker/compose/up_nginx.sh
Normal file
2
docker/compose/up_nginx.sh
Normal file
|
@ -0,0 +1,2 @@
|
|||
docker compose up -d nginx
|
||||
|
Loading…
Add table
Reference in a new issue