Skip to content

Commits on Source 3

# [3.0.0](https://gitlab.ics.muni.cz/perun-proxy-aai/python/perun-proxygui/compare/v2.0.1...v3.0.0) (2023-09-11)
### Features
* mfa reset page ([5d54192](https://gitlab.ics.muni.cz/perun-proxy-aai/python/perun-proxygui/commit/5d54192dc906b19531ce8d93df1edf081c76c80d))
### BREAKING CHANGES
* reworked oidc and jwt
## [2.0.1](https://gitlab.ics.muni.cz/perun-proxy-aai/python/perun-proxygui/compare/v2.0.0...v2.0.1) (2023-08-30)
......
......@@ -3,6 +3,69 @@ bootstrap_color: primary # OPTIONAL: used when option above is not set as MUNI (
MUNI_faculty: fi # OPTIONAL: if not set, default MUNI template will be used - options: fi, fsps, fss, law, med, ped, phil, sci
keystore: path_to_the_keystore # REQUIRED: path to the jwk keystore containing private key needed to decode a message in the corresponding request
key_id: id # REQUIRED: jwk id
general_translations:
format: HTML
sections:
cs:
continue: Pokračovat
cancel: Zrušit
en:
continue: Continue
cancel: Cancel
de:
continue: Weiter
cancel: Abbrechen
mfa_reset:
preferred_mail_attribute: "urn:perun:user:attribute-def:def:preferredMail:" # REQUIRED mail to which MFA reset verification link will be sent
all_mails_attribute: "urn:perun:user:attribute-def:virt:tcsMails:mu" # OPTIONAL mails where notification about the MFA reset will be sent if configured
helpdesk_mail: "it@muni.cz" # REQUIRED mail where request to reset MFA will be forwarded after being confirmed by the user
mail_login_credentials_filepath: path_to_credentials # OPTIONAL credentials to email from which the MFA reset link and notifications will be sent in a newline-delimited file like username<\n>password
smtp_server: smtp.example.com # REQUIRED server from which the signed emails will be sent
smtp_port: 487
cert_filepath: certificate_filepath # REQUIRED for S/MIME signatures used in emails
private_key_filepath: private_key_filepath # REQUIRED for S/MIME signatures used in emails
mfa_reset_translations: # OPTIONAL: options below need to be filled out in order for mfa-reset page text to appear
format: HTML
sections:
cs:
disclaimer: "Opravdu si přejete resetovat Vaše vícefázové ověření?"
explanation: "Restarování povede k ..."
email_sent: "Mail s odkazem na resetovaní Vášho vícefaktorového ověření byl odeslán na emailovou adresou:"
close_site: "Nyní můžete zavřít tuhle stránku. Kliknutím na odkaz v mailu dojde k resetování Vášho vícefaktorového ověření."
request_success_title: "Vaše žádost o resetováni vícefaktorvého ověření byla úspěšně podána."
request_success_info: "Nyní můžete zavřít tuhle stránku."
request_fail_title: "Vaše žádost o resetováni vícefaktorvého ověření selhala."
request_fail_info: "Tenhle odkaz nejspíš byl v minulosti využitý. Svou žádost můžete zkusit opakovat."
reset_link_email_subject: "Odkaz pro resetovaní služby vícefaktorového ověření"
reset_link_email_content: "Resetovaní služby vícefaktorového ověření dokončíte kliknutím na následující odkaz:"
reset_notification_email_subject: "Resetovaní vícefaktorového ověření"
reset_notification_email_content: "Proces resetovaní vícefaktorového ověření byl zahájen."
en:
disclaimer: "Do you really wish to reset your Multi-Factor Authentication?"
explanation: "Restarting will lead to ..."
email_sent: "An email with a link to reset your Multi-Factor Authentication has been sent to the following email address:"
close_site: "Now you can close this site. Clicking the link in the mail will reset your Multi-Factor Authentication."
request_success_title: "Your request to reset your Multi-Factor Authentication has been successfully submitted."
request_success_info: "Now you can close this site."
request_fail_title: "Your request to reset your Multi-Factor Authentication has failed."
request_fail_info: "This link has probably been already used in the past. You may try again to initiate the reset."
reset_link_email_subject: "Link for the reset of Multi-Factor Authentication"
reset_link_email_content: "You will finish reseting your Multi-Factor Authentication by clicking the following link:"
reset_notification_email_subject: "Multi-Factor Authentication reset"
reset_notification_email_content: "The process of Multi-Factor Authentication reset has been initiated."
de:
disclaimer: "Möchten Sie Ihre Multi-Faktor-Authentifizierung wirklich zurücksetzen?"
explanation: "Der Neustart führt zu ..."
email_sent: "Eine E-Mail mit einem Link zum Zurücksetzen Ihrer Multi-Faktor-Authentifizierung wurde an die E-Mail-Adresse gesendet:"
close_site: "Sie können diese Seite nun schließen. Klicken Sie auf den Link in der E-Mail, um Ihre Multi-Faktor-Authentifizierung zurückzusetzen."
request_success_title: "Ihre Anfrage zum Zurücksetzen Ihrer Multi-Faktor-Authentifizierung wurde erfolgreich übermittelt."
request_success_info: "Sie können diese Seite nun schließen."
request_fail_title: "Ihre Anfrage zum Zurücksetzen der Multi-Faktor-Authentifizierung ist fehlgeschlagen."
request_fail_info: "Dieser Link wurde wahrscheinlich schon einmal verwendet. Sie können erneut versuchen, die Rücksetzung einzuleiten."
reset_link_email_subject: "Link zum Zurücksetzen der Multi-Faktor-Authentifizierung"
reset_link_email_content: "Um das Zurücksetzen des Multi-Faktor-Authentifizierungsdienstes abzuschließen, klicken Sie auf den folgenden Link:"
reset_notification_email_subject: "Zurücksetzen der Multi-Faktor-Authentifizierung"
reset_notification_email_content: "Der Prozess der Rücksetzung der Multi-Faktor-Authentifizierung hat begonnen."
footer: # OPTIONAL: options below need to be filled out in order for footer text to appear
format: HTML
sections:
......@@ -61,11 +124,19 @@ consent_database: # REQUIRED
database_name: database_name
consent_collection_name: collection_name
ticket_collection_name: collection_name
oauth2_provider: # REQUIRED data for a shared Oauth2 configuration for protection of some endpoints
issuer: "https://id.muni.cz/"
name: provider_name
client_id: id
client_secret: secret
jwt_nonce_database: # REQUIRED
connection_string: connection_string
database_name: database_name
collection_name: jwt_nonce_collection_name
oidc_provider: # REQUIRED for OAuth2/OIDC protection of some endpoints
client_id: client_id
client_secret: client_secret
provider_name: my_oidc_provider
issuer: url
scopes:
- openid
- perun_consent_api
oidc_redirect_uri: redirect_uri
post_logout_redirect_uris:
- uri1
- uri2
......@@ -16,7 +16,7 @@ logger = Logger.get_logger(__name__)
def get_ban_collection(user_manager: UserManager) -> Collection:
return user_manager.get_mongo_db_collection("ban_database")
return user_manager.database_service.get_mongo_db_collection("ban_database")
def is_ban_in_db(ban_id: int, ban_collection: Collection) -> bool:
......
import json
from http import HTTPStatus
from authlib.integrations.flask_oauth2 import current_token
......@@ -13,7 +12,7 @@ from flask import (
)
from perun.connector import Logger
from perun.proxygui.jwt import verify_jwt
from perun.proxygui.jwt import JWTService
from perun.proxygui.oauth import require_oauth
from perun.proxygui.user_manager import UserManager
from perun.utils.consent_framework.consent import Consent
......@@ -29,11 +28,9 @@ def construct_consent_api(cfg):
consent_api = Blueprint("consent_framework", __name__)
db_manager = ConsentManager(cfg)
user_manager = UserManager(cfg)
jwt_service = JWTService(cfg)
KEY_ID = cfg["key_id"]
KEYSTORE = cfg["keystore"]
oauth_cfg = cfg["oauth2_provider"]
oauth_cfg = cfg["oidc_provider"]
@consent_api.route("/verify/<consent_id>")
def verify(consent_id):
......@@ -49,7 +46,7 @@ def construct_consent_api(cfg):
if request.method == "POST":
jwt = request.values.get("jwt")
try:
jwt = json.loads(verify_jwt(jwt, KEYSTORE, KEY_ID))
jwt = jwt_service.verify_jwt(jwt)
ticket = db_manager.save_consent_request(jwt)
return ticket
except InvalidConsentRequestError as e:
......@@ -100,7 +97,7 @@ def construct_consent_api(cfg):
def consents():
scopes = current_token.scopes
sub = scopes.get("sub")
issuer = cfg["oauth2_provider"]["issuer"]
issuer = cfg["oidc_provider"]["issuer"]
user_id = user_manager.sub_to_user_id(sub, issuer)
if not user_id:
error_message = f"Could not fetch user ID for subject ID '{sub}'"
......
......@@ -6,6 +6,8 @@ import yaml
from cryptojwt.key_jar import init_key_jar
from flask import Flask, request, 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
......@@ -80,6 +82,23 @@ def get_rp_config() -> Base:
)
def get_oidc_auth(cfg, app: Flask):
oidc_cfg = cfg["oidc_provider"]
app.config.update(OIDC_REDIRECT_URI=oidc_cfg["oidc_redirect_uri"])
client_metadata = ClientMetadata(
client_id=oidc_cfg["client_id"],
client_secret=oidc_cfg["client_secret"],
post_logout_redirect_uris=oidc_cfg["post_logout_redirect_uris"],
)
provider_config = ProviderConfiguration(
issuer=oidc_cfg["issuer"], client_metadata=client_metadata
)
return OIDCAuthentication({oidc_cfg["provider_name"]: provider_config})
def get_flask_app(cfg):
if "css_framework" not in cfg:
cfg["css_framework"] = "bootstrap"
......@@ -93,9 +112,10 @@ def get_flask_app(cfg):
return session.get("lang", "en")
app = Flask(__name__)
app.static_url_path = ""
app.static_folder = "gui/static"
app.jinja_loader = jinja2.FileSystemLoader("perun/proxygui/gui/templates")
Babel(app, locale_selector=get_locale)
app.secret_key = cfg["secret_key"]
app.config["SERVER_NAME"] = cfg["host"]["server_name"]
......@@ -104,15 +124,18 @@ def get_flask_app(cfg):
def inject_conf_var():
return dict(cfg=cfg, lang=get_locale())
# initialize OIDC
auth = get_oidc_auth(cfg, app)
# Register GUI component
app.register_blueprint(construct_gui_blueprint(cfg))
app.register_blueprint(construct_gui_blueprint(cfg, auth))
# Register API endpoints
app.register_blueprint(construct_ban_api_blueprint(cfg))
app.register_blueprint(construct_kerberos_auth_api_blueprint(cfg))
# to avoid breaking change
if "consent" in cfg:
oauth_cfg = cfg["oauth2_provider"]
oauth_cfg = cfg["oidc_provider"]
configure_resource_protector(oauth_cfg)
app.register_blueprint(construct_consent_api(cfg))
......
import copy
import json
from uuid import uuid4
import flask
import yaml
from flask import Blueprint
from flask import Blueprint, request, url_for
from flask import render_template, make_response, jsonify, session
from flask_babel import gettext
from flask_babel import gettext, get_locale
from flask_pyoidc.user_session import UserSession
from perun.proxygui.jwt import verify_jwt
from perun.proxygui.jwt import JWTService
from perun.proxygui.user_manager import UserManager
from perun.utils.consent_framework.consent_manager import ConsentManager
......@@ -23,19 +23,19 @@ def ignore_claims(ignored_claims, claims):
return result
def construct_gui_blueprint(cfg):
def construct_gui_blueprint(cfg, auth):
gui = Blueprint("gui", __name__, template_folder="templates")
consent_db_manager = ConsentManager(cfg)
user_manager = UserManager(cfg)
jwt_service = JWTService(cfg)
REDIRECT_URL = cfg["redirect_url"]
COLOR = cfg["bootstrap_color"]
OIDC_CFG = cfg["oidc_provider"]
KEY_ID = cfg["key_id"]
KEYSTORE = cfg["keystore"]
@gui.route("/authorization/<message>")
def authorization(message):
message = json.loads(verify_jwt(message, KEYSTORE, KEY_ID))
@gui.route("/authorization/<token>")
def authorization(token):
message = jwt_service.verify_jwt(token)
email = message.get("email")
service = message.get("service")
registration_url = message.get("registration_url")
......@@ -43,7 +43,7 @@ def construct_gui_blueprint(cfg):
return make_response(
jsonify({gettext("fail"): gettext("Missing request parameter")}),
400,
) # noqa
)
return render_template(
"authorization.html",
email=email,
......@@ -52,9 +52,9 @@ def construct_gui_blueprint(cfg):
bootstrap_color=COLOR,
)
@gui.route("/SPAuthorization/<message>")
def sp_authorization(message):
message = json.loads(verify_jwt(message, KEYSTORE, KEY_ID))
@gui.route("/SPAuthorization/<token>")
def sp_authorization(token):
message = jwt_service.verify_jwt(token)
email = message.get("email")
service = message.get("service")
registration_url = message.get("registration_url")
......@@ -74,9 +74,9 @@ def construct_gui_blueprint(cfg):
bootstrap_color=COLOR,
)
@gui.route("/consent/<ticket>")
def consent(ticket):
ticket = json.loads(verify_jwt(ticket, KEYSTORE, KEY_ID))
@gui.route("/consent/<token>")
def consent(token):
ticket = jwt_service.verify_jwt(token)
data = consent_db_manager.fetch_consent_request(ticket)
if not ticket:
return make_response(
......@@ -115,4 +115,46 @@ def construct_gui_blueprint(cfg):
warning=warning,
)
@auth.oidc_auth(OIDC_CFG["provider_name"])
@gui.route("/mfa-reset-verify/<token>")
def mfa_reset_verify(token):
reset_request = jwt_service.verify_jwt(token)
if reset_request:
requester_email = reset_request.get("requester_email")
user_manager.forward_mfa_reset_request(requester_email)
return render_template(
"MfaResetVerifyConfirmationSuccess.html",
)
else:
return render_template(
"MfaResetVerifyConfirmationFail.html",
)
@auth.oidc_auth(OIDC_CFG["provider_name"])
@gui.route("/send-mfa-reset-emails")
def send_mfa_reset_emails():
user_session = UserSession(flask.session)
sub = user_session.userinfo.get("sub")
issuer = OIDC_CFG["issuer"]
user_id = user_manager.sub_to_user_id(sub, issuer)
locale = get_locale().language
preferred_email = user_manager.handle_mfa_reset(
user_id, locale, url_for("gui.mfa_reset_verify")
)
return render_template(
"MfaResetEmailSent.html",
email=preferred_email,
)
@auth.oidc_auth(OIDC_CFG["provider_name"])
@gui.route("/mfa-reset")
def mfa_reset():
return render_template(
"MfaResetInitiated.html",
redirect_url=REDIRECT_URL,
bootstrap_color=COLOR,
referrer=request.referrer or "/",
send_mfa_reset_emails=url_for("gui.send_mfa_reset_emails"),
)
return gui
{% extends 'base.html' %}
{% block contentwrapper %}
<div
class="window {% if cfg.css_framework == 'MUNI' %}framework_muni{% else %}framework_bootstrap5 bg-light{% endif %}">
<div id="content">
{% block content %}
<div class="content">
<br />
{% if cfg.mfa_reset_translations.sections[lang] is not none %}
{% if cfg.mfa_reset_translations.format is not none and (cfg.mfa_reset_translations.format == 'HTML' or cfg.mfa_reset_translations.format == 'markdown') %}
<h3><span>{{ cfg.mfa_reset_translations.sections[lang].email_sent | safe }} '{{ email }}'</span></h3>
<div class="margin-bottom-24 wrap-col"><span>{{ cfg.mfa_reset_translations.sections[lang].close_site | safe }}</span></div>
{% else %}
<h3><span>{{ cfg.mfa_reset_translations.sections[lang].email_sent }} '{{ email }}'</span></h3>
<div class="margin-bottom-24 wrap-col"><span>{{ cfg.mfa_reset_translations.sections[lang].close_site }}</span></div>
{% endif %}
{% endif %}
{% 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 %}">
<div id="content">
{% block content %}
<div class="content">
<br />
{% if cfg.mfa_reset_translations.sections[lang] is not none %}
{% if cfg.mfa_reset_translations.format is not none and (cfg.mfa_reset_translations.format == 'HTML' or cfg.mfa_reset_translations.format == 'markdown') %}
<h3><span>{{ cfg.mfa_reset_translations.sections[lang].disclaimer | safe }}</span></h3>
<div class="margin-bottom-24 wrap-col"><span>{{ cfg.mfa_reset_translations.sections[lang].explanation | safe }}</span></div>
{% else %}
<h3><span>{{ cfg.mfa_reset_translations.sections[lang].disclaimer }}</span></h3>
<div class="margin-bottom-24 wrap-col"><span>{{ cfg.mfa_reset_translations.sections[lang].explanation }}</span></div>
{% endif %}
{% endif %}
<div class="row-main">
<div class="grid">
<div class="grid__cell size--l--3-12 size--s--1-2">
<p class="btn-wrap--wide">
<a href="{{ referrer }}" class="btn btn-primary btn-s btn-border">
<span class="no-uppercase">{{ cfg.general_translations.sections[lang].cancel }}</span>
</a>
</p>
</div>
<div class="margin-left-24 grid__cell size--l--3-12 size--s--1-2">
<p class="btn-wrap--wide">
<a href="{{ send_mfa_reset_emails }}" class="btn btn-primary btn-s btn-accept">
<span class="no-uppercase">{{ cfg.general_translations.sections[lang].continue }}</span>
</a>
</a>
</p>
</div>
</div>
</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 %}">
<div id="content">
{% block content %}
<div class="content">
<br />
{% if cfg.mfa_reset_translations.sections[lang] is not none %}
{% if cfg.mfa_reset_translations.format is not none and (cfg.mfa_reset_translations.format == 'HTML' or cfg.mfa_reset_translations.format == 'markdown') %}
<h3><span>{{ cfg.mfa_reset_translations.sections[lang].request_fail_title | safe }}</span></h3>
<div class="margin-bottom-24 wrap-col"><span>{{ cfg.mfa_reset_translations.sections[lang].request_fail_info | safe }}</span></div>
{% else %}
<h3><span>{{ cfg.mfa_reset_translations.sections[lang].request_fail_title }}</span></h3>
<div class="margin-bottom-24 wrap-col"><span>{{ cfg.mfa_reset_translations.sections[lang].request_fail_info }}</span></div>
{% endif %}
{% endif %}
{% 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 %}">
<div id="content">
{% block content %}
<div class="content">
<br />
{% if cfg.mfa_reset_translations.sections[lang] is not none %}
{% if cfg.mfa_reset_translations.format is not none and (cfg.mfa_reset_translations.format == 'HTML' or cfg.mfa_reset_translations.format == 'markdown') %}
<h3><span>{{ cfg.mfa_reset_translations.sections[lang].request_success_title | safe }}</span></h3>
<div class="margin-bottom-24 wrap-col"><span>{{ cfg.mfa_reset_translations.sections[lang].request_success_info | safe }}</span></div>
{% else %}
<h3><span>{{ cfg.mfa_reset_translations.sections[lang].request_success_title }}</span></h3>
<div class="margin-bottom-24 wrap-col"><span>{{ cfg.mfa_reset_translations.sections[lang].request_success_info }}</span></div>
{% endif %}
{% endif %}
{% endblock %}
</div>
</div>
</div>
{% endblock %}
from jwcrypto import jwk, jwt
import datetime
import json
import secrets
from authlib.jose import jwt
from jwcrypto import jwk
from jwcrypto.jwk import JWKSet, JWK
from typing_extensions import Dict, Any
from perun.utils.DatabaseService import DatabaseService
class JWTService:
def __init__(self, cfg):
self.__KEYSTORE = cfg.get("keystore")
self.__KEY_ID = cfg.get("key_id")
self.__JWK_SET = None
self.__DATABASE_SERVICE = DatabaseService(cfg)
def import_keys(file_path: str) -> JWKSet:
def __import_keys(self) -> JWKSet:
jwk_set = jwk.JWKSet()
with open(file_path, "r") as file:
with open(self.__KEYSTORE, "r") as file:
jwk_set.import_keyset(file.read())
return jwk_set
def __get_signing_jwk(self) -> JWK:
jwk_set = self.__JWK_SET if self.__JWK_SET else self.__import_keys()
return jwk_set.get_key(self.__KEY_ID)
def verify_jwt(self, token) -> Dict[Any, Any]:
"""
Verifies that the JWT is valid - it is not expired and hasn't been
used yet.
:param token: JWT to verify
:return: content of the JWT if it's valid, empty dict otherwise
"""
jwk_key = self.__get_signing_jwk()
claims = jwt.JWT(jwt=token, key=jwk_key).claims
message = json.loads(claims)
# verify that the token is not expired
expiration_date = message.get("exp")
if datetime.datetime.now() >= expiration_date:
return {}
# verify that the token hasn't been used yet
nonce = message.get("nonce")
jwt_nonce_collection = self.__DATABASE_SERVICE.get_mongo_db_collection(
"jwt_nonce_database"
)
is_used_nonce = (
jwt_nonce_collection.count_documents({"used_nonce": nonce}, limit=1) > 0
)
if is_used_nonce:
return {}
jwt_nonce_collection.insert_one({"used_nonce": nonce})
return message
def get_jwt(self, token_args: Dict[str, Any], lifetime_hours: int = 24) -> bytes:
"""
Constructs a signed JWT containing expiration time and nonce by
default. Other attributes to be added can be passed in token_args.
def get_signing_jwk(file: str, key: str) -> JWK:
jwk_set = import_keys(file)
return jwk_set.get_key(key)
:param token_args: dict of attributes to be added to the signed JWT
:param lifetime_hours: How long should the token stay valid
:return: signed and encoded JWT
"""
token_info = {
"nonce": secrets.token_urlsafe(16),
"exp": datetime.datetime.utcnow()
+ datetime.timedelta(hours=lifetime_hours),
}
if token_args:
token_info.update(token_args)
signing_key = self.__get_signing_jwk()
encoded_token = jwt.encode(payload=token_info, key=signing_key)
def verify_jwt(token, keystore, key_id):
jwk_key = get_signing_jwk(keystore, key_id)
return jwt.JWT(jwt=token, key=jwk_key).claims
return encoded_token
......@@ -2,6 +2,7 @@ from http import HTTPStatus
from typing import Dict, Optional
import requests
import validators
from authlib.integrations.flask_oauth2 import ResourceProtector
from authlib.oauth2.rfc6750 import InvalidTokenError, InsufficientScopeError
from authlib.oauth2.rfc7662 import IntrospectTokenValidator
......@@ -45,6 +46,9 @@ logger = Logger.get_logger(__name__)
def get_introspect_url(issuer: str) -> Optional[str]:
metadata_endpoint = f"{issuer}.well-known/openid-configuration'"
if not validators.url(metadata_endpoint):
return None
response = requests.get(metadata_endpoint)
if response.status_code != HTTPStatus.OK:
......
import json
from unittest.mock import patch
import pytest
......@@ -45,7 +44,7 @@ def test_authorization_error(client):
assert response.status_code == 404
@patch("perun.proxygui.gui.gui.verify_jwt")
@patch("perun.proxygui.gui.gui.JWTService.verify_jwt")
def test_authorization(mock_method, client):
test_data = {
"email": "email",
......@@ -53,7 +52,6 @@ def test_authorization(mock_method, client):
"registration_url": "url",
}
test_result = json.dumps(test_data)
is_testing_sp_text = "Access forbidden"
is_testing_sp_text_2 = (
"You don't meet the prerequisites for accessing the service: " # noqa
......@@ -66,7 +64,7 @@ def test_authorization(mock_method, client):
# noqa
)
is_testing_sp_text_5 = "Problem with login to service: "
mock_method.return_value = test_result
mock_method.return_value = test_data
response = client.get("/authorization/example")
result = response.data.decode()
......@@ -84,21 +82,20 @@ def test_sp_authorization_error(client):
assert response.status_code == 404
@patch("perun.proxygui.gui.gui.verify_jwt")
@patch("perun.proxygui.gui.gui.JWTService.verify_jwt")
def test_sp_authorization(mock_method, client):
test_data = {
"email": "mail",
"service": "service",
"registration_url": "url",
}
test_result = json.dumps(test_data)
is_testing_sp_text = "You are not authorized to access the service "
is_testing_sp_text_2 = (
"We will now redirect you to a registration page, "
+ "where you will apply for the access."
)
is_testing_sp_text_3 = "Proceed to registration"
mock_method.return_value = test_result
mock_method.return_value = test_data
response = client.get("/SPAuthorization/example")
result = response.data.decode()
......
from typing import Any, Optional
import copy
from typing import Any
from typing import Optional
import sqlalchemy
from perun.connector import AdaptersManager
from perun.connector import Logger
from pymongo import MongoClient
from pymongo.collection import Collection
from sqlalchemy import MetaData
from sqlalchemy import delete, select
......@@ -11,6 +12,8 @@ from sqlalchemy.engine import Engine
from sqlalchemy.orm.session import Session
from perun.utils.ConfigStore import ConfigStore
from perun.utils.DatabaseService import DatabaseService
from perun.utils.EmailService import EmailService
class UserManager:
......@@ -21,16 +24,19 @@ class UserManager:
self._ADAPTERS_MANAGER = AdaptersManager(ADAPTERS_MANAGER_CFG, ATTRS_MAP)
self._SUBJECT_ATTRIBUTE = cfg.get("perun_person_principal_names_attribute")
self._PREFERRED_MAIL_ATTRIBUTE = cfg["mfa_reset"]["preferred_mail_attribute"]
self._ALL_MAILS_ATTRIBUTE = cfg["mfa_reset"]["all_mails_attribute"]
self.email_service = EmailService(cfg)
self.database_service = DatabaseService(cfg)
self.logger = Logger.get_logger(__name__)
self._cfg = cfg
def get_mongo_db_collection(self, cfg_db_name: str) -> Collection:
client = MongoClient(self._cfg[cfg_db_name]["connection_string"])
database_name = self._cfg[cfg_db_name]["database_name"]
collection_name = self._cfg[cfg_db_name]["collection_name"]
def extract_user_attribute(self, attr_name: str, user_id: int) -> Any:
user_attrs = self._ADAPTERS_MANAGER.get_user_attributes(user_id, [attr_name])
attr_value_candidates = user_attrs.get(attr_name, [])
attr_value = attr_value_candidates[0] if attr_value_candidates else None
return client[database_name][collection_name]
return attr_value
def _revoke_ssp_sessions(
self,
......@@ -102,7 +108,8 @@ class UserManager:
matching_sid & matching_username
)
elif user_id:
# if only user id is present, we delete all tokens associated with the user
# if only user id is present, we delete all tokens associated
# with the user
user_auth = session.query(SAVED_USER_AUTH_TBL.c.id).filter(
matching_username
)
......@@ -155,10 +162,10 @@ class UserManager:
return deleted_mitre_tokens_count
def _get_satosa_sessions_collection(self) -> Collection:
return self.get_mongo_db_collection("satosa_database")
return self.database_service.get_mongo_db_collection("satosa_database")
def _get_ssp_sessions_collection(self) -> Collection:
return self.get_mongo_db_collection("ssp_database")
return self.database_service.get_mongo_db_collection("ssp_database")
def sub_to_user_id(self, sub: str, issuer: str) -> Optional[str]:
"""
......@@ -179,26 +186,26 @@ class UserManager:
) -> None:
"""
Performs revocation of user's sessions based on the provided user_id or
session_id. If none are provided, revocation is not performed. If both are
provided, only a single session is revoked if it exists. If only user id is
session_id. If none are provided, revocation is not performed. If
both are
provided, only a single session is revoked if it exists. If only
user id is
provided, all of user's sessions are revoked.
:param user_id: id of user whose sessions are to be revoked
:param session_id: id of a specific session to revoke
:param include_refresh_tokens: specifies whether refresh tokens should be
:param include_refresh_tokens: specifies whether refresh tokens
should be
canceled as well
:return: Nothing
"""
if not user_id:
self.logger.info(
"No user id provided. Please, provide at least user id to perform "
"No user id provided. Please, provide at least user id to "
"perform "
"logout."
)
return
user_attrs = self._ADAPTERS_MANAGER.get_user_attributes(
int(user_id), [self._SUBJECT_ATTRIBUTE]
)
subject_candidates = user_attrs.get(self._SUBJECT_ATTRIBUTE, [])
subject = subject_candidates[0] if subject_candidates else None
subject = self.extract_user_attribute(self._SUBJECT_ATTRIBUTE, int(user_id))
satosa_sessions_collection = self._get_satosa_sessions_collection()
revoked_grants_count = self._revoke_satosa_grants(
......@@ -222,15 +229,12 @@ class UserManager:
def get_active_client_ids_for_user(self, user_id: str) -> set[str]:
"""
Returns list of unique client ids retrieved from active user's sessions.
Returns list of unique client ids retrieved from active user's
sessions.
:param user_id: user, whose sessions are retrieved
:return: list of client ids
"""
user_attrs = self._ADAPTERS_MANAGER.get_user_attributes(
int(user_id), [self._SUBJECT_ATTRIBUTE]
)
subject_candidates = user_attrs.get(self._SUBJECT_ATTRIBUTE, [])
subject = subject_candidates[0] if subject_candidates else None
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)
......@@ -291,3 +295,29 @@ class UserManager:
{"sub": sub}, {"client_id": 1, "_id": 0}
)
return list(result)
def handle_mfa_reset(
self, user_id: str, locale: str, mfa_reset_verify_url: str
) -> str:
# send MFA reset confirmation link
preferred_mail = self.extract_user_attribute(
self._PREFERRED_MAIL_ATTRIBUTE, int(user_id)
)
self.email_service.send_mfa_reset_link(
preferred_mail, locale, mfa_reset_verify_url
)
# send notification about MFA reset
if self._ALL_MAILS_ATTRIBUTE:
all_user_mails = self.extract_user_attribute(
self._ALL_MAILS_ATTRIBUTE, int(user_id)
)
non_preferred_mails = copy.deepcopy(all_user_mails)
if preferred_mail in all_user_mails:
non_preferred_mails.remove(preferred_mail)
self.email_service.send_mfa_reset_notification(non_preferred_mails, locale)
return preferred_mail
def forward_mfa_reset_request(self, requester_email: str) -> None:
self.email_service.send_mfa_reset_request(requester_email)
from pymongo import MongoClient
from pymongo.collection import Collection
class DatabaseService:
def __init__(self, cfg):
self.__CFG = cfg
def get_mongo_db_collection(self, cfg_db_name: str) -> Collection:
client = MongoClient(self.__CFG[cfg_db_name]["connection_string"])
database_name = self.__CFG[cfg_db_name]["database_name"]
collection_name = self.__CFG[cfg_db_name]["collection_name"]
return client[database_name][collection_name]
import os
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import List
from smail import sign_message
from perun.proxygui.jwt import JWTService
class EmailService:
def __init__(self, cfg):
self.__SMTP_SERVER = cfg["mfa_reset"]["smtp_server"]
self.__SMTP_PORT = cfg["mfa_reset"]["smtp_port"]
self.__CERT_FILE = cfg["mfa_reset"]["cert_filepath"]
self.__HELPDESK_EMAIL = cfg["mfa_reset"]["helpdesk_mail"]
self.__PRIVATE_KEY = cfg["mfa_reset"]["private_key_filepath"]
self.__TRANSLATIONS = cfg["mfa_reset_translations"]["sections"]
self.__JWT_SERVICE = JWTService(cfg)
self.__LOGIN_EMAIL = None
self.__LOGIN_PASS = None
credentials_path = cfg["mfa_reset"].get("mail_login_credentials_filepath")
if credentials_path and os.path.exists(credentials_path):
with open(credentials_path, "r") as credentials:
self.__LOGIN_EMAIL, self.__LOGIN_PASS = credentials.read().split("\n")
def __send_email_message(self, message: MIMEMultipart) -> None:
with smtplib.SMTP(self.__SMTP_SERVER, self.__SMTP_PORT) as smtp_server:
smtp_server.ehlo()
smtp_server.starttls()
smtp_server.ehlo()
if self.__LOGIN_EMAIL and self.__LOGIN_PASS:
smtp_server.login(self.__LOGIN_EMAIL, self.__LOGIN_PASS)
smtp_server.send_message(message)
def __send_signed_email_message(self, message: MIMEMultipart) -> None:
signed_message = sign_message(message, self.__PRIVATE_KEY, self.__CERT_FILE)
self.__send_email_message(signed_message)
def send_mfa_reset_link(
self, recipient_email: str, locale: str, mfa_reset_verify_url: str
) -> None:
message = MIMEMultipart("related")
message["From"] = self._LOGIN_EMAIL
message["Subject"] = self.__TRANSLATIONS[locale]["reset_link_email_subject"]
message["To"] = recipient_email
jwt = self.__JWT_SERVICE.get_jwt({"requester_email": recipient_email})
message_content = (
self.__TRANSLATIONS[locale]["reset_link_email_content"]
+ f" {mfa_reset_verify_url}/{jwt}"
)
message.attach(MIMEText(message_content, "plain", _charset="UTF-8"))
self.__send_signed_email_message(message)
def send_mfa_reset_notification(
self, recipient_emails: List[str], locale: str
) -> None:
message = MIMEMultipart("related")
message["From"] = self._LOGIN_EMAIL
message["Subject"] = self.__TRANSLATIONS[locale][
"reset_notification_email_subject"
]
message["To"] = ", ".join(recipient_emails)
message_content = self.__TRANSLATIONS[locale][
"reset_notification_email_content"
]
message.attach(MIMEText(message_content, "plain", _charset="UTF-8"))
self.__send_signed_email_message(message)
def send_mfa_reset_request(self, requester_email: str):
message = MIMEMultipart("related")
message["From"] = self._LOGIN_EMAIL
message["Subject"] = "MFA reset request"
message["To"] = self.__HELPDESK_EMAIL
message_content = (
f"Partly verified user with email '"
f"{requester_email}' has requested a reset of "
f"their Multi-Factor Authentication."
)
message.attach(MIMEText(message_content, "plain", _charset="UTF-8"))
self.__send_signed_email_message(message)
[metadata]
version = 2.0.1
version = 3.0.0
license_files = LICENSE
......@@ -20,11 +20,14 @@ setup(
"setuptools",
"PyYAML~=6.0",
"Flask~=2.2",
"Flask-pyoidc~=3.14",
"jwcrypto~=1.3",
"Flask-Babel~=3.1",
"perun.connector~=3.7",
"python-smail~=0.9.0",
"SQLAlchemy~=2.0.19",
"pymongo~=4.4.1",
"validators~=0.22.0",
"idpyoidc~=2.0.0",
],
extras_require={
......