Skip to content
Snippets Groups Projects
Commit ee82e9e2 authored by Johana Supíková's avatar Johana Supíková
Browse files

feat: logout system

parent 813ec2c4
Branches
No related tags found
No related merge requests found
Pipeline #321235 failed
Showing
with 943 additions and 70 deletions
......@@ -5,12 +5,14 @@ import jinja2
import yaml
from cryptojwt.key_jar import init_key_jar
from flask import Flask, request, session
from flask_session import Session
from flask_babel import Babel
from flask_pyoidc import OIDCAuthentication
from flask_pyoidc.provider_configuration import ClientMetadata, ProviderConfiguration
from idpyoidc.client.configure import Configuration, RPHConfiguration
from idpyoidc.client.rp_handler import RPHandler
from idpyoidc.configure import create_from_config_file, Base
from pymongo import MongoClient
from perun.proxygui.api.backchannel_logout_api import (
construct_backchannel_logout_api_blueprint,
......@@ -120,6 +122,17 @@ def get_flask_app(cfg):
app.config["SERVER_NAME"] = cfg["host"]["server_name"]
if "session_database" in cfg:
app.config["SESSION_TYPE"] = "mongodb"
app.config["SESSION_MONGODB"] = MongoClient(
cfg["session_database"]["connection_string"]
)
app.config["SESSION_MONGODB_DB"] = cfg["session_database"]["database_name"]
app.config["SESSION_MONGODB_COLLECT"] = cfg["session_database"][
"collection_name"
]
Session(app)
@app.context_processor
def inject_conf_var():
return dict(cfg=cfg, lang=get_locale())
......
import copy
from uuid import uuid4
import flask
import yaml
from flask import Blueprint, request, url_for
from flask import render_template, make_response, jsonify, session
from flask import Blueprint, request, url_for, render_template, make_response, jsonify, session
from flask_babel import gettext, get_locale
from flask_pyoidc.user_session import UserSession
from perun.proxygui.jwt import JWTService
from perun.proxygui.user_manager import UserManager
from flask import (
Blueprint,
request,
render_template,
make_response,
jsonify,
session,
redirect,
)
from flask_babel import gettext
from perun.connector.utils import Logger
from perun.proxygui.logout_manager import LogoutManager
from perun.utils.consent_framework.consent_manager import ConsentManager
from perun.proxygui.user_manager import UserManager
logger = Logger.Logger.get_logger(__name__)
def ignore_claims(ignored_claims, claims):
result = dict()
......@@ -28,6 +42,7 @@ def construct_gui_blueprint(cfg, auth):
consent_db_manager = ConsentManager(cfg)
user_manager = UserManager(cfg)
jwt_service = JWTService(cfg)
logout_manager = LogoutManager(cfg)
REDIRECT_URL = cfg["redirect_url"]
COLOR = cfg["bootstrap_color"]
......@@ -66,6 +81,167 @@ def construct_gui_blueprint(cfg, auth):
bootstrap_color=COLOR,
)
@gui.route("/logout", methods=["POST", "GET"])
def logout():
ssp_session_id = request.cookies.get("SimpleSAMLSessionID")
user_id = user_manager.get_user_id_by_ssp_session_id(ssp_session_id)
if ssp_session_id is None or user_id is None:
return render_template("MissingAuth.html")
(
valid_request,
client_id,
rp_sid,
logout_params,
) = logout_manager.validate_request_and_extract_params(session, request)
if valid_request:
user_manager.logout_from_service_op(user_id, rp_sid, client_id)
device_active_clients = user_manager.get_active_client_ids_for_user(user_id)
session_active_clients = user_manager.get_active_client_ids_for_session(
request.cookies.get("SimpleSAMLSessionID")
)
rp_names = user_manager.get_all_rp_names()
logged_out_service = (
{
"from_devices": rp_sid
is None, # user might have been logged out for all devices
"labels": rp_names.get(client_id, client_id),
}
if client_id is not None
else None
)
session_services = logout_manager.complete_service_names(
session_active_clients, rp_names
)
device_services = logout_manager.complete_service_names(
device_active_clients, rp_names
)
session["logout_params"] = logout_params if logout_params else {}
resp = make_response(
render_template(
"Logout.html",
logged_out_service=logged_out_service,
session_services=session_services,
device_services=device_services,
bootstrap_color=COLOR,
)
)
return resp
@gui.route("/logout_state", methods=["GET"])
def logout_state():
ssp_session_id = request.cookies.get("SimpleSAMLSessionID")
user_id = user_manager.get_user_id_by_ssp_session_id(ssp_session_id)
if ssp_session_id is None or user_id is None:
return render_template("MissingAuth.html")
include_all_devices = request.args.get("from_devices", False)
include_all_devices = include_all_devices in ["True", True, "true"]
if include_all_devices:
active_clients = user_manager.get_active_client_ids_for_user(user_id)
unique_client_issuer_clients = []
for (client_id, sid, issuer) in active_clients:
found = next(filter(lambda x: x[0] == client_id and x[2] == issuer, unique_client_issuer_clients), None)
unique_client_issuer_clients.append((client_id, sid, issuer)) if not found else None
active_clients = unique_client_issuer_clients
else:
active_clients = user_manager.get_active_client_ids_for_session(
ssp_session_id
)
rp_names = user_manager.get_all_rp_names()
# todo - jazyky brát z config option languages - brát průnik, issuer bude mapa {issuer: pretty_name}
# todo - pěkná funkce na vyčítání (fallback když je jenom japonská verze atd...)
service_configs = logout_manager.fetch_services_configuration()
logout_requests = [
logout_manager.prepare_logout_request(
service_configs,
client_id,
user_id,
rp_names.get(client_id, {"en": client_id, "cs": client_id}),
issuer,
rp_sid if include_all_devices else None,
).to_dict()
for (client_id, rp_sid, issuer) in active_clients
]
session["logout_requests"] = logout_requests
# user_manager.logout(
# user_id = None, session_id=ssp_session_id, include_refresh_tokens=include_all_devices
# ) todo - currently timeouts, user_id != sub
resp = make_response(
render_template(
"logout-state.html",
session_services=logout_requests if not include_all_devices else [],
device_services=logout_requests if include_all_devices else [],
bootstrap_color=COLOR,
)
)
#resp.delete_cookie("SimpleSAMLSessionID") todo - keep commented for testing
return resp
@gui.route("/post_logout")
def post_logout():
logout_params = session.get("logout_params")
if logout_params is None: # can be empty but should exist
return render_template("MissingAuth.html")
if "post_logout_redirect_uri" in logout_params:
url = logout_params["post_logout_redirect_uri"]
if "state" in logout_params:
url = (
f"{url}?{logout_params['state']}"
if "?" not in url
else f"{url}&{logout_params['state']}"
)
return redirect(url)
# todo - retrieve 'log back in' URL from config and pass to template
return render_template(
"Post-logout.html",
bootstrap_color=COLOR,
)
@gui.route("/logout_iframe_callback", methods=["GET"])
def logout_iframe_callback():
request_id = request.args.get("request_id")
logout_requests = session["logout_requests"]
current_request = next(
(r for r in logout_requests if str(r["id"]) == request_id), None
)
if current_request is None:
response = False
else:
req = logout_manager.deserialize_request(current_request)
response = req.logout()
return render_template(
"logout-iframe.html",
result="success" if response else response,
)
@gui.route("/logout_saml_callback", methods=["GET"])
def logout_saml_callback():
request_ok = logout_manager.check_saml_callback(request)
return render_template(
"logout-iframe.html",
result="success" if request_ok else "request invalid",
)
@gui.route("/IsTestingSP")
def is_testing_sp():
return render_template(
......
<svg xmlns="http://www.w3.org/2000/svg" height="1.15em" viewBox="0 0 512 512"><!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><style>svg{fill:#34ad00}</style><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" height="2em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><style>svg{fill:#34ad00}</style><path d="M256 48a208 208 0 1 1 0 416 208 208 0 1 1 0-416zm0 464A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-111 111-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l64 64c9.4 9.4 24.6 9.4 33.9 0L369 209z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" height="1.15em" viewBox="0 0 512 512"><!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><style>svg{fill:#ff0000}</style><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24V264c0 13.3-10.7 24-24 24s-24-10.7-24-24V152c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" height="1.15em" viewBox="0 0 512 512"><!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><style>svg{fill:#0052e0}</style><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM169.8 165.3c7.9-22.3 29.1-37.3 52.8-37.3h58.3c34.9 0 63.1 28.3 63.1 63.1c0 22.6-12.1 43.5-31.7 54.8L280 264.4c-.2 13-10.9 23.6-24 23.6c-13.3 0-24-10.7-24-24V250.5c0-8.6 4.6-16.5 12.1-20.8l44.3-25.4c4.7-2.7 7.6-7.7 7.6-13.1c0-8.4-6.8-15.1-15.1-15.1H222.6c-3.4 0-6.4 2.1-7.5 5.3l-.4 1.2c-4.4 12.5-18.2 19-30.6 14.6s-19-18.2-14.6-30.6l.4-1.2zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" height="1.15em" viewBox="0 0 512 512"><!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><style>svg{fill:#0752d5}</style><path d="M304 48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zm0 416a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM48 304a48 48 0 1 0 0-96 48 48 0 1 0 0 96zm464-48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM142.9 437A48 48 0 1 0 75 369.1 48 48 0 1 0 142.9 437zm0-294.2A48 48 0 1 0 75 75a48 48 0 1 0 67.9 67.9zM369.1 437A48 48 0 1 0 437 369.1 48 48 0 1 0 369.1 437z"/></svg>
\ No newline at end of file
.spinner {
animation: spin 2s linear infinite;
}
.my-flex-column {
display: flex;
flex-direction: column;
align-items: center;
}
.my-flex-row {
text-align: left;
display: flex;
align-items: center;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.margin-top-1em {
margin-top: 1em;
}
\ No newline at end of file
function set_service_icon(iframe_id, icon_path) {
const service_id = iframe_id.slice(iframe_id.indexOf("_") + 1);
let spinner = $(`#${service_id}`).children("img.spinner");
spinner.attr("src", icon_path)
spinner.removeClass("spinner")
}
function logoutSuccess(iframe_id, success_img_path) {
set_service_icon(iframe_id, success_img_path)
}
function logoutFailure(iframe_id, failure_img_path) {
set_service_icon(iframe_id, failure_img_path)
}
{% extends 'base.html' %}
{% block contentwrapper %}
<div
class="window {% if cfg.css_framework == 'MUNI' %}framework_muni{% else %}framework_bootstrap5 bg-light{% endif %}">
<div
class="wrap{% if not cfg.css_framework == 'MUNI' %} d-flex flex-column gap-2{% endif %}">
<br>
<h4>{{ _("Do you really want to log out from") }}
{{ logged_out_service }}?
</h4>
<p class="{% if cfg.css_framework == 'MUNI' %}btn-wrap{% else %}d-flex flex-row justify-content-center gap-3{% endif %}">
<button onclick=""
class="btn btn-primary btn-s">
<span class="no-uppercase">{{ _('Yes') }}</span>
</button>
<button onclick=""
class="btn btn-primary btn-s">
<span class="no-uppercase">{{ _('No') }}</span>
</button>
</p>
</div>
</div>
{% endblock %}
{% extends 'base.html' %}
{% block contentwrapper %}
<div
class="window {% if cfg.css_framework == 'MUNI' %}framework_muni{% else %}framework_bootstrap5 bg-light{% endif %}">
<div
class="wrap{% if not cfg.css_framework == 'MUNI' %} d-flex flex-column gap-2{% endif %}">
{% if logged_out_service is not none %}
{% if cfg.css_framework == 'MUNI' %}
<h2 class="margin-top-1em">{{ _("Logout Successful") }}</h2>
{% else %}
<h1 class="margin-top-1em">{{ _("Logout Successful") }}</h1>
{% endif %}
<h4>{{ _("You have successfully logged out from ") }}
{{ logged_out_service }}.
</h4>
{% else %}
{% if cfg.css_framework == 'MUNI' %}
<h2 class="margin-top-1em">{{ _("Logout Page") }}</h2>
{% else %}
<h1 class="margin-top-1em">{{ _("Logout Page") }}</h1>
{% endif %}
{% endif %}
<hr>
<div class="my-flex-column">
<div style="text-align: start">
<h4>{{ _("Would you like to log out from the services below?") }}</h4>
<p class="{% if cfg.css_framework == 'MUNI' %}btn-wrap{% else %}d-flex flex-row gap-3 ms-3{% endif %}">
<a href="{{ url_for('gui.logout_state', from_devices=False) }}"
class="btn btn-primary btn-s">
<span class="no-uppercase">{{ _('Log out') }}</span>
</a>
<a href="{{ url_for('gui.logout_state', from_devices=True) }}"
class="btn btn-primary btn-s">
<span
class="no-uppercase">{{ _('Log out from devices') }}</span>
</a>
<a href="{{ url_for('gui.post_logout') }}"
class="btn {% if cfg.css_framework == 'MUNI' %}btn-primary{% else %}btn-secondary{% endif %} btn-s btn-border">
<span class="no-uppercase">{{ _('No') }}</span>
</a>
</p>
<h4>{{ _("You can log out from the following services:") }}</h4>
<ul>
{% for service in session_services %}
<li>{{ service.rp_names.get(lang) }} </li>
{% endfor %}
</ul>
<h4>{{ _("You can log out from these services on your other devices:") }}</h4>
<ul>
{% for service in device_services %}
<li>{{ service.rp_names.get(lang) }} </li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
{% extends 'base.html' %}
{% block contentwrapper %}
<div class="window {% if cfg.css_framework == 'MUNI' %}framework_muni{% else %}framework_bootstrap5 bg-light{% endif %}">
<div id="content">
<div class="wrap{% if not cfg.css_framework == 'MUNI' %} container{% endif %}">
{% block content %}
<div class="content">
<br/>
<h1><span>{{ _("Cannot perform action") }}</span></h1>
<br/>
<p><span>{{ _("Missing cookies - action cannot be performed.") }}</span> {{ service }}</p>
</div>
{% endblock %}
</div>
</div>
</div>
{% endblock %}
{% extends 'base.html' %}
{% block contentwrapper %}
<div
class="window {% if cfg.css_framework == 'MUNI' %}framework_muni{% else %}framework_bootstrap5 bg-light{% endif %}">
<br>
<div class="my-flex-column">
<div class="my-flex-row">
<img src="{{ url_for("static", filename="images/circle-check-regular.svg") }}">
<h2 class="margin-left-24">{{ _("Logout successful") }}</h2>
</div>
</div>
<br>
</div>
{% endblock %}
......@@ -62,7 +62,7 @@
</div>
</div>
<div class="grid__cell size--m--2-4 pull--m--2-4 center">
<img src="{% if cfg.logo is not none %}{{ cfg.logo }}{% endif %}" class="header-img" alt="{% if cfg.name is not none %}{{ cfg.name }}{% endif %}"/>
<img src="{% if cfg.logo is not none %}{{ url_for('static', filename=cfg.logo) }}{% endif %}" class="header-img" alt="{% if cfg.name is not none %}{{ cfg.name }}{% endif %}"/>
</div>
</div>
</div>
......@@ -107,7 +107,7 @@
{% endif %}
</div>
<div class="col-md-6 order-md-first text-md-start">
<img src="{% if cfg.logo is not none %}{{ cfg.logo }}{% endif %}" class="header-img mt-3" alt="{% if cfg.name is not none %}{{ cfg.name }}{% endif %}"/>
<img src="{% if cfg.logo is not none %}{{ url_for('static', filename=cfg.logo) }}{% endif %}" class="header-img mt-3" alt="{% if cfg.name is not none %}{{ cfg.name }}{% endif %}"/>
</div>
</div>
</div>
......
{% filter trim %}
<!DOCTYPE html>
<html lang="{{ locale }}" xml:lang="{{ locale }}">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="initial-scale=1.0">
<title>{{ pagetitle }}</title>
{# skip default CSS, favicon and JavaScript #}
<meta name="robots" content="noindex, nofollow">
{% block preload %}
{% if cfg.css_framework == 'MUNI' %}
<link rel="stylesheet" type="text/css" href="/static/selectize/css/selectize.css"/>
<link rel="stylesheet" type="text/css" href="/static/MuniWeb/css/style{% if cfg.MUNI_faculty is not none%}-{{ cfg.MUNI_faculty }}{% endif %}.css"/>
{% else %}
<link rel="stylesheet" type="text/css" href="/static/font_awesome/css/all.css"/>
<link rel="stylesheet" type="text/css" href="/static/selectize/css/selectize.bootstrap5.css"/>
<link href="/static/bootstrap/css/bootstrap.min.css" rel="stylesheet">
{% endif %}
<link rel="stylesheet" type="text/css" href="/static/campus-idp.css"/>
{% endblock %}
</head>
<body id="{{ templateId }}" class="{% if cfg.css_framework == 'MUNI' %}framework_muni{% else %}framework_bootstrap5{% endif %}">
<!DOCTYPE html>
<html lang="{{ locale }}" xml:lang="{{ locale }}">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="initial-scale=1.0">
<title>{{ pagetitle }}</title>
{# skip default CSS, favicon and JavaScript #}
<meta name="robots" content="noindex, nofollow">
{% block preload %}
{% if cfg.css_framework == 'MUNI' %}
<link rel="stylesheet" type="text/css"
href="{{ url_for("static", filename="selectize/css/selectize.css") }}" />
{% set muni_stylesheet_url = "MuniWeb/css/style.css" %}
{% if "Muni_faculty" in cfg %}
{% set muni_stylesheet_url = "MuniWeb/css/style-" + cfg.Muni_faculty + ".css" %}
{% endif %}
<link rel="stylesheet" type="text/css"
href="{{ url_for("static", filename=muni_stylesheet_url) }}" />
{% else %}
<link rel="stylesheet" type="text/css"
href="{{ url_for("static", filename="font_awesome/css/all.css") }}" />
<link rel="stylesheet" type="text/css"
href="{{ url_for("static", filename="selectize/css/selectize.bootstrap5.css") }}" />
<link rel="stylesheet" type="text/css"
href="{{ url_for("static", filename="bootstrap/css/bootstrap.min.css") }}" />
{% endif %}
<link rel="stylesheet" type="text/css"
href="{{ url_for("static", filename="campus-idp.css") }}" />
<link rel="stylesheet" type="text/css"
href="{{ url_for("static", filename="proxygui.css") }}" />
{% endblock %}
</head>
<body id="{{ templateId }}"
class="{% if cfg.css_framework == 'MUNI' %}framework_muni{% else %}framework_bootstrap5{% endif %}">
<div id="layout">
{% block header %}{% include "_header.html" %}{% endblock %}
{% block contentwrapper %}
<div id="content">
<div class="wrap">
{% block content %}{% endblock %}
</div>
</div>{# content #}
{% endblock contentwrapper %}
</div>{# layout #}
{% block header %}{% include "_header.html" %}{% endblock %}
{% block contentwrapper %}
<div id="content">
<div class="wrap">
{% block content %}{% endblock %}
</div>
</div>{# content #}
{% endblock contentwrapper %}
</div>
{# layout #}
<div>
{% block footer %}{% include "_footer.html" %}{% endblock %}
{% block footer %}{% include "_footer.html" %}{% endblock %}
</div>
{% block postload %}
{% if cfg.css_framework == 'MUNI' %}
<script src="/static/jquery-3.6.0.min.js"></script>
<script src="/static/MuniWeb/js/app.js"></script>
<script src="/static/campus-idp-muni.js"></script>
{% else %}
<script src="/static/bootstrap/js/bootstrap.bundle.min.js"></script>
{% endif %}
<script src="/static/selectize/js/standalone/selectize.min.js"></script>
<script type="module" src="/static/campus-idp.js"></script>
{% if cfg.css_framework == 'MUNI' %}
<script
src="{{ url_for("static", filename="MuniWeb/js/app.js") }}"></script>
<script
src="{{ url_for("static", filename="campus-idp-muni.js") }}"></script>
{% else %}
<script
src="{{ url_for("static", filename="bootstrap/js/bootstrap.bundle.min.js") }}"></script>
{% endif %}
<script src="{{ url_for("static", filename="selectize/js/standalone/selectize.min.js") }}"></script>
<script src="{{ url_for("static", filename="campus-idp.js") }}"></script>
<script src="{{ url_for("static", filename="jquery-3.6.0.min.js") }}"></script>
<script src="{{ url_for("static", filename="proxygui.js") }}"></script>
{% endblock %}
</body>
</html>
</body>
</html>
{% endfilter %}
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Logout result</title>
</head>
<body>
<script src="{{ url_for("static", filename="proxygui.js") }}"></script>
<script>
onload = () => {
{% if result == "success" %}
window.parent.logoutSuccess(window.frameElement.id, "{{ url_for("static", filename="images/check.svg") }}");
{% else %}
window.parent.logoutFailure(window.frameElement.id, "{{ url_for("static", filename="images/circle-exclamation-solid.svg") }}");
{% endif %}
};
</script>
</body>
</html>
\ No newline at end of file
{% extends 'base.html' %}
{% block contentwrapper %}
<div
class="window {% if cfg.css_framework == 'MUNI' %}framework_muni{% else %}framework_bootstrap5 bg-light{% endif %}">
<div
class="wrap{% if not cfg.css_framework == 'MUNI' %} d-flex flex-column gap-2{% endif %}">
{% if cfg.css_framework == 'MUNI' %}
<h2 class="margin-top-1em">{{ _("Performing log out") }}</h2>
{% else %}
<h1 class="margin-top-1em">{{ _("Performing log out") }}</h1>
{% endif %}
<hr>
<p class="{% if cfg.css_framework == 'MUNI' %}btn-wrap{% else %}d-flex flex-row justify-content-center{% endif %}">
<a href="{{ url_for('gui.post_logout') }}"
class="btn btn-primary btn-s">
<span class="no-uppercase">{{ _('Continue') }}</span>
</a>
</p>
<div class="my-flex-column">
<div style="text-align: start">
{% if session_services|length != 0 %}
<h4 class="margin-top-1em">{{ _("Performing logout from following services: ") }}</h4>
{% for service in session_services %}
<div id="{{ service.id }}"
class="process my-flex-row">
{% if not service.iframe_src %}
<img src="{{ url_for("static", filename="images/circle-exclamation-solid.svg") }}">
{% elif service.front_channel %}
<img src="{{ url_for("static", filename="images/questionmark.svg") }}">
<iframe src="{{ service.iframe_src }}" hidden></iframe>
{% else %}
<img src="{{ url_for("static", filename="images/spinner.svg") }}"
class="spinner">
<iframe id="iframe_{{ service.id }}" src="{{ service.iframe_src }}" hidden></iframe>
{% endif %}
<div
class="margin-left-12">{{ service.rp_names.get(lang) }}</div>
</div>
{% endfor %}
{% endif %}
{% if device_services|length != 0 %}
<h4 class="margin-top-1em">{{ _("Performing logout from following services on your other devices:") }}</h4>
{% for service in device_services %}
<div id="{{ service.id }}"
class="process my-flex-row">
{% if not service.iframe_src %}
<img src="{{ url_for("static", filename="images/circle-exclamation-solid.svg") }}">
{% elif service.logout_type == "FRONTCHANNEL_LOGOUT" %}
<img src="{{ url_for("static", filename="images/questionmark.svg") }}">
<iframe src="{{ service.iframe_src }}" hidden></iframe>
{% else %}
<img src="{{ url_for("static", filename="images/spinner.svg") }}"
class="spinner">
<iframe id="iframe_{{ service.id }}" src="{{ service.iframe_src }}" hidden></iframe>
{% endif %}
<div
class="margin-left-12">{{ service.rp_names.get(lang) }}</div>
</div>
{% endfor %}
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
from datetime import datetime
import yaml
from perun.connector import Logger
from perun.proxygui.jwt import JWTService
from perun.proxygui.user_manager import UserManager
from perun.utils import Utils
from perun.utils.logout_requests.BackchannelLogoutRequest import (
BackchannelLogoutRequest,
)
from perun.utils.logout_requests.FrontchannelLogoutRequest import (
FrontchannelLogoutRequest,
)
from perun.utils.logout_requests.GraphLogoutRequest import GraphLogoutRequest
from perun.utils.logout_requests.LogoutRequest import LogoutRequest
from perun.utils.logout_requests.SamlLogoutRequest import SamlLogoutRequest
import copy
class LogoutManager:
def __init__(self, cfg):
self.key_id = cfg["key_id"]
self.keystore = cfg["keystore"]
self.user_manager = UserManager(cfg)
self.jwt_service = JWTService(cfg)
self.logger = Logger.get_logger(__name__)
self._cfg = cfg
def session_exists(self, ss_sid, is_ss_session):
ssp_sessions_collection = Utils.get_mongo_db_collection(
"ssp_database", self._cfg
)
if is_ss_session:
entries = ssp_sessions_collection.count_documents(
{"type": "session", "key": ss_sid}
)
else:
entries = 0
return entries > 0
def validate_request_and_extract_params(self, session, request):
logout_methods = [
self._resolve_rp_initiated_logout_request,
self._resolve_saml_initiated_logout_request,
self._resolve_rp_initiated_alternative_logout_request,
]
valid, client_id, rp_sid, other_params = False, None, None, {}
for logout_method in logout_methods:
valid, client_id, rp_sid, other_params = logout_method(
session=session, request=request
)
if valid:
return valid, client_id, rp_sid, other_params
return valid, client_id, rp_sid, other_params
def _resolve_saml_initiated_logout_request(self, session, request):
# todo - SAML SLO
# also add redirect URL to other params
return False, None, None, None
def _resolve_rp_initiated_alternative_logout_request(self, session, request):
INVALID_REQUEST = False, None, None, None
logout_token = (
request.args.get("logout_token")
if request.form.get("logout_token") is None
else request.form.get("logout_token")
)
client_id = (
request.args.get("client_id")
if request.form.get("client_id") is None
else request.form.get("client_id")
)
if logout_token is None:
return INVALID_REQUEST
if (
"client_id" in logout_token
and client_id is not None
and logout_token["client_id"] != client_id
):
return INVALID_REQUEST
try:
# todo - select key by issuer (selected by endpoint url = request.url_root)
logout_token = self.jwt_service.verify_jwt(logout_token, self.keystore, self.key_id)
except Exception:
return INVALID_REQUEST
events = logout_token.get("events")
if events is None or events != "https://openid.net/specs/openid-connect-rpinitiated-1_0.html%22":
return INVALID_REQUEST
sid = logout_token.get("sid")
return True, client_id, sid, {}
def _resolve_rp_initiated_logout_request(self, session, request):
INVALID_REQUEST = False, None, None, None
id_token_hint = (
request.args.get("id_token_hint")
if request.form.get("id_token_hint") is None
else request.form.get("id_token_hint")
)
post_logout_redirect_uri = (
request.args.get("post_logout_redirect_uri")
if request.form.get("post_logout_redirect_uri") is None
else request.form.get("post_logout_redirect_uri")
)
client_id = (
request.args.get("client_id")
if request.form.get("client_id") is None
else request.form.get("client_id")
)
if id_token_hint is None:
return INVALID_REQUEST
try:
# todo - select key by issuer (selected by endpoint url = request.url_root)
logout_token = self.jwt_service.verify_jwt(id_token_hint, self.keystore, self.key_id)
except Exception:
return INVALID_REQUEST
if (
"client_id" in id_token_hint
and client_id is not None
and id_token_hint["client_id"] != client_id
):
return INVALID_REQUEST
rp_sid = self._convert_ssp_sid_to_rp_sid(client_id, session["session_id"])
if rp_sid is None or (
"sid" in id_token_hint and id_token_hint["sid"] != rp_sid
):
return INVALID_REQUEST
if (
post_logout_redirect_uri is not None
and not self._is_redirect_uri_registered(
client_id, post_logout_redirect_uri
)
):
return INVALID_REQUEST
params = {
"client_id": client_id,
"post_logout_redirect_uri": post_logout_redirect_uri,
}
if "state" in id_token_hint:
params["state"] = id_token_hint.get("state")
return True, client_id, rp_sid, params
def _convert_ssp_sid_to_rp_sid(self, client_id, ssp_sid):
rp_sid = self._get_rp_sid_from_sspid_satosa(client_id, ssp_sid)
if rp_sid is None:
self._get_rp_sid_from_sspid_ssp(client_id, ssp_sid)
return rp_sid
def _get_rp_sid_from_sspid_satosa(self, client_id, ssp_sid):
satosa_sessions_collection = Utils.get_mongo_db_collection(
"satosa_database", self._cfg
)
entry = satosa_sessions_collection.find_one(
{"client_id": client_id, "claims.ssp_session_id": ssp_sid},
{"sid_encrypted": 1},
)
return entry.get("sid_encrypted") if entry else None
def _get_rp_sid_from_sspid_ssp(self, client_id, ssp_sid):
ssp_sessions_collection = Utils.get_mongo_db_collection(
"ssp_database", self._cfg
)
current_datetime = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
entries = ssp_sessions_collection.find(
{
"$or": [
{
"$and": [
{"type": "session"},
{"key": ssp_sid},
{"expire": {"$gt": current_datetime}},
]
},
{"$and": [{"type": "session"}, {"key": ssp_sid}, {"expire": None}]},
]
},
{"session_indexes_detail": 1, "_id": 0},
)
for entry in entries:
session_details = entry.get("session_indexes_detail", {})
for issuer, data in session_details.items():
for sp, sid in data.items():
if sp == client_id:
return sid
return None
def _is_redirect_uri_registered(self, client_id, post_logout_redirect_uri):
# todo - check, if the redirect uri is allowed for the client
return False
def fetch_services_configuration(self):
# todo - retrieve configurations for Graph API, metadata and global config properties
# currently only for testing purpose:
test_graph_config = "/etc/graph.perun.proxygui.yaml"
with open(test_graph_config, "r", encoding="utf8") as f:
return yaml.safe_load(f)
def prepare_logout_request(
self, services_config, client_id, sub, rp_names, issuer, rp_sid=None
):
rp_config = services_config.get("RPS", {}).get(client_id, None)
if rp_config is None:
request = LogoutRequest(issuer, client_id, rp_names)
elif "BACKCHANNEL_LOGOUT" in rp_config:
request = BackchannelLogoutRequest(issuer, client_id, rp_names)
elif "FRONTCHANNEL_LOGOUT" in rp_config:
request = FrontchannelLogoutRequest(issuer, client_id, rp_names)
elif "GRAPH_LOGOUT" in rp_config:
request = GraphLogoutRequest(issuer, client_id, rp_names)
elif "SAML_LOGOUT" in rp_config:
request = SamlLogoutRequest(issuer, client_id, rp_names)
else:
request = LogoutRequest(issuer, client_id, rp_names)
request.prepare_logout(services_config, sub, rp_sid)
return request
def deserialize_request(self, data):
logout_type = data.get("logout_type")
if logout_type == "BACKCHANNEL_LOGOUT":
return BackchannelLogoutRequest.from_dict(data)
elif logout_type == "GRAPH_LOGOUT":
return GraphLogoutRequest.from_dict(data)
elif logout_type == "SAML_LOGOUT":
return SamlLogoutRequest.from_dict(data)
else:
return LogoutRequest.from_dict(data)
def complete_service_names(self, clients_data, rp_names):
# todo - jazyky brát z config option languages - brát průnik, issuer bude mapa {issuer: pretty_name}
# todo - pěkná funkce na vyčítání (fallback když je jenom japonská verze atd...)
client_ids = {} # client_id: [issuer1, issuer2]
names = []
for (client_id, _, issuer) in clients_data:
if client_id not in client_ids:
client_ids[client_id] = []
if issuer not in client_ids[client_id]:
client_ids[client_id].append(issuer)
for (client_id, issuers) in client_ids.items():
client_names = rp_names.get(client_id, {"en": client_id, "cs": client_id})
if len(issuers) > 1:
for issuer in issuers:
base_names = copy.deepcopy(client_names)
for lang in base_names:
base_names[lang] = f"{issuer}: {base_names[lang]}"
names.append(base_names)
else:
names.append(client_names)
return names
def check_saml_callback(self, request):
# todo - check saml LogoutResponse
return False
import copy
import json
from datetime import datetime
from typing import Any
from typing import Optional
......@@ -11,6 +13,7 @@ from sqlalchemy import delete, select
from sqlalchemy.engine import Engine
from sqlalchemy.orm.session import Session
from perun.utils import Utils
from perun.utils.ConfigStore import ConfigStore
from perun.utils.DatabaseService import DatabaseService
from perun.utils.EmailService import EmailService
......@@ -28,6 +31,9 @@ class UserManager:
self._ALL_MAILS_ATTRIBUTE = cfg["mfa_reset"]["all_mails_attribute"]
self.email_service = EmailService(cfg)
self.database_service = DatabaseService(cfg)
self._KEY_ID = cfg["key_id"]
self._KEYSTORE = cfg["keystore"]
self.logger = Logger.get_logger(__name__)
self._cfg = cfg
......@@ -45,6 +51,7 @@ class UserManager:
session_id: str = None,
) -> int:
if session_id:
# todo - shouldn't be user??? then it's the same and kvstore usage can be replaced with this one?
result = ssp_sessions_collection.delete_many(
{"sub": subject, "key": session_id}
)
......@@ -60,18 +67,29 @@ class UserManager:
satosa_sessions_collection: Collection,
subject: str = None,
session_id: str = None,
client_id: str = None,
) -> int:
if session_id:
result = satosa_sessions_collection.delete_many(
{"sub": subject, "claims.ssp_session_id": session_id}
)
elif subject:
result = satosa_sessions_collection.delete_many({"sub": subject})
else:
if not subject and not session_id:
return 0
query = {"sub": subject}
if client_id is not None:
query["client_id"] = client_id
if session_id is not None:
query["claims.ssp_session_id"] = session_id
result = satosa_sessions_collection.delete_many(query)
return result.deleted_count
def _get_satosa_service_session_id(
self,
satosa_sessions_collection: Collection,
client_id: str = None,
session_id: str = None,
):
return satosa_sessions_collection.find_one(
{"claims.ssp_session_id": session_id, "client_id": client_id}
)
def _get_postgres_engine(self) -> Engine:
connection_string = self._cfg["mitre_database"]["connection_string"]
engine = sqlalchemy.create_engine(connection_string)
......@@ -178,6 +196,7 @@ class UserManager:
if user:
return str(user.id)
def logout(
self,
user_id: str = None,
......@@ -192,6 +211,7 @@ class UserManager:
user id is
provided, all of user's sessions are revoked.
:param user_id: id of user whose sessions are to be revoked
:param subject: sub in case it needn't be retrieved from IdM
:param session_id: id of a specific session to revoke
:param include_refresh_tokens: specifies whether refresh tokens
should be
......@@ -227,6 +247,13 @@ class UserManager:
f"deleted {deleted_tokens_count} mitre tokens."
)
def logout_from_service_op(self, subject, session_id, client_id):
satosa_sessions_collection = self._get_satosa_sessions_collection()
self._revoke_satosa_grants(
satosa_sessions_collection, subject, session_id, client_id
)
# todo - add more op token removal options? (remove single ssp sessionIndex entry?)
def get_active_client_ids_for_user(self, user_id: str) -> set[str]:
"""
Returns list of unique client ids retrieved from active user's
......@@ -235,14 +262,21 @@ class UserManager:
:return: list of client ids
"""
subject = self.extract_user_attribute(self._SUBJECT_ATTRIBUTE, int(user_id))
ssp_clients = self._get_ssp_entity_ids_by_user(subject)
satosa_clients = self._get_satosa_client_ids(subject)
mitre_clients = self._get_mitre_client_ids(user_id)
satosa_clients = self._get_satosa_client_ids_by_user(subject)
# mitre_clients = self._get_mitre_client_ids_by_user(user_id)
return ssp_clients + satosa_clients
def get_active_client_ids_for_session(self, session_id: str):
ssp_clients = self._get_ssp_entity_ids_by_session(session_id)
satosa_clients = self._get_satosa_client_ids_by_session(session_id)
# SKIP - mitre
return set(ssp_clients + satosa_clients + mitre_clients)
return ssp_clients + satosa_clients
def _get_mitre_client_ids(self, user_id: str) -> list[str]:
def _get_mitre_client_ids_by_user(self, user_id: str) -> list[str]:
# todo - remove ? probably won't be used
engine = self._get_postgres_engine()
meta_data = MetaData()
meta_data.reflect(engine)
......@@ -273,28 +307,104 @@ class UserManager:
result = cnxn.execute(stmt)
return [r[0] for r in result]
def get_user_id_by_ssp_session_id(self, session_id: str):
if session_id is None:
return None
ssp_sessions_collection = self._get_ssp_sessions_collection()
current_datetime = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
entry = ssp_sessions_collection.find_one(
{
"$or": [
{
"$and": [
{"type": "session"},
{"key": session_id},
{"expire": {"$gt": current_datetime}},
]
},
{
"$and": [
{"type": "session"},
{"key": session_id},
{"expire": None},
]
},
]
}
)
return entry.get("user") if entry is not None else None
def _get_ssp_entity_ids_by_user(self, sub: str):
ssp_sessions_collection = self._get_ssp_sessions_collection()
entries = ssp_sessions_collection.find(
{"user": sub}, {"entityIds": 1, "_id": 0}
{"user": sub}, {"session_indexes_detail": 1, "_id": 0}
)
entries = [entry.get("entityIds", []) for entry in entries]
return [el for lst in entries for el in lst]
result = []
for entry in entries:
session_details = entry.get("session_indexes_detail", {})
for issuer, data in session_details.items():
for sp, sid in data.items():
result.append((sp, sid, issuer))
return result
def _get_ssp_entity_ids_by_session(self, session_id: str):
ssp_sessions_collection = self._get_ssp_sessions_collection()
current_datetime = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
entries = ssp_sessions_collection.find(
{"key": session_id}, {"entityIds": 1, "_id": 0}
{
"$or": [
{
"$and": [
{"type": "session"},
{"key": session_id},
{"expire": {"$gt": current_datetime}},
]
},
{
"$and": [
{"type": "session"},
{"key": session_id},
{"expire": None},
]
},
]
},
{"session_indexes_detail": 1, "_id": 0},
)
entries = [entry.get("entityIds", []) for entry in entries]
return [el for lst in entries for el in lst]
def _get_satosa_client_ids(self, sub: str):
result = []
for entry in entries:
session_details = entry.get("session_indexes_detail", {})
for issuer, data in session_details.items():
for sp, sid in data.items():
result.append((sp, sid, issuer))
return result
def _get_satosa_client_ids_by_user(self, sub: str):
search_argument = {"sub": sub}
return self._get_satosa_active_sessions(search_argument)
def _get_satosa_client_ids_by_session(self, session_id: str):
search_argument = {"claims.ssp_session_id": session_id}
return self._get_satosa_active_sessions(search_argument)
def _get_satosa_active_sessions(self, search_argument):
satosa_sessions_collection = self._get_satosa_sessions_collection()
result = satosa_sessions_collection.find(
{"sub": sub}, {"client_id": 1, "_id": 0}
entries = satosa_sessions_collection.find(
search_argument,
{"client_id": 1, "sid_encrypted": 1, "id_token": 1, "_id": 0},
)
return list(result)
result = []
for entry in entries:
client_id = entry.get("client_id")
sid = entry.get("sid_encrypted")
issuer = self._get_issuer_from_id_token(entry.get("id_token"))
result.append((client_id, sid, issuer))
return result
def handle_mfa_reset(
self, user_id: str, locale: str, mfa_reset_verify_url: str
......@@ -321,3 +431,28 @@ class UserManager:
def forward_mfa_reset_request(self, requester_email: str) -> None:
self.email_service.send_mfa_reset_request(requester_email)
def _get_issuer_from_id_token(self, id_token):
# todo - key will be per issuer?
# claims = json.loads(verify_jwt(id_token, self._KEYSTORE, self._KEY_ID))
# return claims.get("iss")
return None
def get_all_rp_names(self):
"""
Returns structure of {service_name: {'cs': cs_label, 'en': en_label} from attribute
"""
result = {}
names = self._ADAPTERS_MANAGER.get_entityless_attribute(
"urn:perun:entityless:attribute-def:def:mfaCategories"
)
if "categories" not in names:
self.logger.warn(
"Attribute containing services names not returned or format is invalid!"
)
return {}
services_structure = json.loads(names["categories"])
for category in services_structure:
result = result | services_structure[category]["rps"]
return result
from pymongo import MongoClient
from pymongo.collection import Collection
def get_mongo_db_collection(cfg_db_name: str, cfg) -> Collection:
client = MongoClient(cfg[cfg_db_name]["connection_string"])
database_name = cfg[cfg_db_name]["database_name"]
collection_name = cfg[cfg_db_name]["collection_name"]
return client[database_name][collection_name]
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment