Skip to content
Snippets Groups Projects
Commit 5073ace8 authored by Peter Bolha's avatar Peter Bolha :ok_hand_tone1:
Browse files

feat: extend consent management api

BREAKING CHANGE: `oauth2_provider` now required in config
parent 32cd3bbf
No related branches found
No related tags found
1 merge request!27feat: extend consent management api
Pipeline #312640 passed
......@@ -61,3 +61,11 @@ 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
scopes:
- openid
- perun_consent_api
......@@ -34,10 +34,18 @@ def construct_backchannel_logout_api_blueprint(cfg, logout_cfg):
return Response(error_message, HTTPStatus.INTERNAL_SERVER_ERROR)
else:
try:
logout_params = current_app.rp_handler.backchannel_logout(
sub, sid, issuer = current_app.rp_handler.backchannel_logout(
client, request_args=request.form
)
user_manager.logout(*logout_params)
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 " f"'{sub}'"
)
return Response(error_message, HTTPStatus.INTERNAL_SERVER_ERROR)
user_manager.logout(user_id=user_id, session_id=sid)
except Exception as ex:
error_message = (
......
import json
from http import HTTPStatus
from authlib.integrations.flask_oauth2 import current_token
from flask import (
Blueprint,
request,
jsonify,
abort,
session,
redirect,
Response,
)
from perun.connector import Logger
from perun.proxygui.jwt import verify_jwt
from flask import Blueprint, request, jsonify, abort, session, redirect
from perun.proxygui.oauth import require_oauth
from perun.proxygui.user_manager import UserManager
from perun.utils.consent_framework.consent import Consent
from perun.utils.consent_framework.consent_manager import (
ConsentManager,
InvalidConsentRequestError,
)
from perun.utils.consent_framework.consent import Consent
logger = Logger.get_logger(__name__)
......@@ -15,10 +28,13 @@ logger = Logger.get_logger(__name__)
def construct_consent_api(cfg):
consent_api = Blueprint("consent_framework", __name__)
db_manager = ConsentManager(cfg)
user_manager = UserManager(cfg)
KEY_ID = cfg["key_id"]
KEYSTORE = cfg["keystore"]
oauth_cfg = cfg["oauth2_provider"]
@consent_api.route("/verify/<consent_id>")
def verify(consent_id):
attrs = db_manager.fetch_consented_attributes(consent_id)
......@@ -76,4 +92,44 @@ def construct_consent_api(cfg):
return redirect(cfg["redirect_url"])
return redirect(redirect_uri)
# scopes in form ['scope1 scope2'] represent logical conjunction in Authlib
required_scopes = [" ".join(oauth_cfg["scopes"])]
@consent_api.route("/users/me/consents", methods=["GET"])
@require_oauth(required_scopes)
def consents():
scopes = current_token.scopes
sub = scopes.get("sub")
issuer = cfg["oauth2_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}'"
return Response(error_message, HTTPStatus.INTERNAL_SERVER_ERROR)
user_consents = db_manager.fetch_all_user_consents(user_id)
return jsonify(user_consents)
@consent_api.route("/users/me/consents/<consent_id>", methods=["DELETE"])
@require_oauth(required_scopes)
def delete_consent(consent_id):
deleted_count = db_manager.delete_user_consent(consent_id)
print("deleted_count", deleted_count)
if deleted_count > 0:
return jsonify(
{
"deleted": "true",
"message": f"Successfully deleted consent with id {consent_id}",
}
)
else:
return jsonify(
{
"deleted": "false",
"message": f"Requested consent with id {consent_id} "
f"was not deleted because it was not found in "
f"the database.",
}
)
return consent_api
......@@ -14,9 +14,12 @@ from perun.proxygui.api.backchannel_logout_api import (
construct_backchannel_logout_api_blueprint,
)
from perun.proxygui.api.ban_api import construct_ban_api_blueprint
from perun.proxygui.api.kerberos_auth_api import construct_kerberos_auth_api_blueprint
from perun.proxygui.api.consent_api import construct_consent_api
from perun.proxygui.api.kerberos_auth_api import construct_kerberos_auth_api_blueprint
from perun.proxygui.gui.gui import construct_gui_blueprint
from perun.proxygui.oauth import (
configure_resource_protector,
)
from perun.utils.CustomRPHandler import CustomRPHandler
PROXYGUI_CFG = "perun.proxygui.yaml"
......@@ -109,6 +112,8 @@ def get_flask_app(cfg):
app.register_blueprint(construct_kerberos_auth_api_blueprint(cfg))
# to avoid breaking change
if "consent" in cfg:
oauth_cfg = cfg["oauth2_provider"]
configure_resource_protector(oauth_cfg)
app.register_blueprint(construct_consent_api(cfg))
logout_cfg = get_config(BACKCHANNEL_LOGOUT_CFG, False)
......
from http import HTTPStatus
from typing import Dict, Optional
import requests
from authlib.oauth2 import ResourceProtector
from authlib.oauth2.rfc6750 import InvalidTokenError, InsufficientScopeError
from authlib.oauth2.rfc7662 import IntrospectTokenValidator
from perun.connector import Logger
class MyIntrospectTokenValidator(IntrospectTokenValidator):
def __init__(self, **extra_attributes):
super().__init__(**extra_attributes)
self.client_id = None
self.client_secret = None
self.introspect_url = None
def introspect_token(self, token_string):
url = self.introspect_url
data = {"token": token_string, "token_type_hint": "access_token"}
auth = (self.client_id, self.client_secret)
response = requests.post(url, data=data, auth=auth)
response.raise_for_status()
return response.json()
def validate_token(self, token, scopes, request):
if not token or not token.get("active"):
raise InvalidTokenError(
realm=self.realm, extra_attributes=self.extra_attributes
)
if token.is_expired() or token.is_revoked():
raise InvalidTokenError()
if self.scope_insufficient(token.get_scope(), scopes):
raise InsufficientScopeError()
return token
require_oauth = ResourceProtector()
my_validator = MyIntrospectTokenValidator()
require_oauth.register_token_validator(my_validator)
logger = Logger.get_logger(__name__)
def get_introspect_url(issuer: str) -> Optional[str]:
metadata_endpoint = f"{issuer}.well-known/openid-configuration'"
response = requests.get(metadata_endpoint)
if response.status_code != HTTPStatus.OK:
logger.info(
f"Introspection url could not be obtained. Metadata endpoint '"
f"{metadata_endpoint}' responded with '{response.status_code} - "
f"{response.json()}'"
)
return None
metadata = response.json()
return metadata.get("introspection_endpoint")
def configure_resource_protector(oauth_cfg: Dict[str, str]) -> None:
validator = require_oauth.get_token_validator(my_validator.TOKEN_TYPE)
validator.client_id = (oauth_cfg["client_id"],)
validator.client_secret = (oauth_cfg["client_secret"],)
validator.introspect_url = get_introspect_url(oauth_cfg["issuer"])
from unittest.mock import patch
import json
from functools import wraps
from http import HTTPStatus
from unittest.mock import patch, Mock
import pytest
from perun.proxygui.api import consent_api
from perun.proxygui.app import get_flask_app, get_config
from perun.proxygui.tests.shared_test_data import SHARED_TESTING_CONFIG, ATTRS_MAP
# decorator that acts as a passthrough function
def mock_decorator(*args, **kwargs):
def decorator(func):
@wraps(func)
def decorated_function(*args, **kwargs):
return func(*args, **kwargs)
return decorated_function
return decorator
# bypass authentication by mocking auth decorator and its configuration
patch("perun.proxygui.api.consent_api.require_oauth", mock_decorator).start()
patch("perun.proxygui.app.configure_resource_protector", lambda x: x).start()
@pytest.fixture()
def client():
def app():
with patch(
"perun.utils.ConfigStore.ConfigStore.get_global_cfg",
return_value=SHARED_TESTING_CONFIG,
......@@ -19,10 +39,15 @@ def client():
cfg = get_config()
app = get_flask_app(cfg)
app.config["TESTING"] = True
yield app.test_client()
yield app
def test_verify_endpoint(client):
USERS_CONSENTS_IN_DB = ["consent1", "consent2"]
def test_verify_endpoint(app):
client = app.test_client()
with patch(
"perun.utils.consent_framework.consent_manager."
"ConsentManager.fetch_consented_attributes",
......@@ -41,7 +66,9 @@ def test_verify_endpoint(client):
assert response.status_code == HTTPStatus.UNAUTHORIZED
def test_save_consent_endpoint(client):
def test_save_consent_endpoint(app):
client = app.test_client()
with client.session_transaction() as session:
session["state"] = "state"
session["attr"] = {"attr1": "value1", "attr2": "value2"}
......@@ -87,3 +114,61 @@ def test_save_consent_endpoint(client):
"yes&month=6&attr1=value1&attr3=value3"
)
assert response.status_code == HTTPStatus.BAD_REQUEST
def test_delete_consent_endpoint(app):
client = app.test_client()
# successful delete of a single existing consent
with patch(
"perun.utils.consent_framework.consent_manager.ConsentManager"
".delete_user_consent",
return_value=1,
):
REAL_CONSENT_ID = "real_consent_id"
response = client.delete(f"/users/me/consents/{REAL_CONSENT_ID}")
response_json = json.loads(response.data)
CONSENT_DELETE_SUCCESS_MSG = (
f"Successfully deleted consent with id {REAL_CONSENT_ID}"
)
assert CONSENT_DELETE_SUCCESS_MSG in str(response_json["message"])
assert response_json["deleted"] == "true"
# unsuccessful delete of a single non-existing consent
with patch(
"perun.utils.consent_framework.consent_manager.ConsentManager"
".delete_user_consent",
return_value=0,
):
FAKE_CONSENT_ID = "fake_consent_id"
response = client.delete(f"/users/me/consents/{FAKE_CONSENT_ID}")
response_json = json.loads(response.data)
CONSENT_DELETE_FAILED_MSG = (
f"Requested consent with id {FAKE_CONSENT_ID} was not "
f"deleted because it was not found in the database."
)
assert CONSENT_DELETE_FAILED_MSG in str(response_json["message"])
assert response_json["deleted"] == "false"
def test_get_all_user_consents_endpoint(app):
client = app.test_client()
TOKEN = Mock()
TOKEN.scopes = {}
with app.test_request_context():
with patch(
"perun.utils.consent_framework.consent_manager.ConsentManager"
".fetch_all_user_consents",
return_value=USERS_CONSENTS_IN_DB,
), patch(
"perun.proxygui.user_manager.UserManager" ".sub_to_user_id",
return_value="existing_user_id",
), patch.object(
consent_api, "current_token", return_value=TOKEN
):
response = client.get("/users/me/consents")
assert json.loads(response.data) == USERS_CONSENTS_IN_DB
assert response.status_code == HTTPStatus.OK
import mongomock
from datetime import datetime
from unittest import TestCase
import mongomock
from perun.utils.consent_framework.consent import Consent
from perun.utils.consent_framework.consent_db import ConsentDB
from datetime import datetime
class TestConsentDB(TestCase):
......@@ -18,9 +20,16 @@ class TestConsentDB(TestCase):
self.mock_client = mongomock.MongoClient()
self.mock_collection = self.mock_client.test_db.test_collection
self.consent_db = ConsentDB(self.cfg, "test_db")
self.consent = Consent(
self.consent1 = Consent(
{"test_attribute": "test_value"},
"test_user_id",
"test_user_id1",
"test_requester",
6,
datetime.utcnow(),
)
self.consent2 = Consent(
{"test_attribute": "test_value"},
"test_user_id2",
"test_requester",
6,
datetime.utcnow(),
......@@ -28,33 +37,83 @@ class TestConsentDB(TestCase):
def test_save_consent(self):
self.consent_db.collection = self.mock_collection
self.consent_db.save_consent("test_consent_id", self.consent)
self.consent_db.save_consent("test_consent_id", self.consent1)
self.assertTrue(
self.mock_collection.find_one({"consent_id": "test_consent_id"})
)
def test_get_consent(self):
self.consent_db.collection = self.mock_collection
self.consent_db.save_consent("test_consent_id", self.consent)
self.consent_db.save_consent("test_consent_id", self.consent1)
result = self.consent_db.get_consent("test_consent_id")
self.assertEqual(result.attributes, {"test_attribute": "test_value"})
self.assertEqual(result.user_id, "test_user_id")
self.assertEqual(result.user_id, "test_user_id1")
self.assertEqual(result.requester, "test_requester")
self.assertEqual(result.months_valid, 6)
def test_delete_consent(self):
self.consent_db.collection = self.mock_collection
self.consent_db.save_consent("test_consent_id", self.consent)
self.consent_db.save_consent("test_consent_id", self.consent1)
self.consent_db.delete_consent("test_consent_id")
self.assertIsNone(
self.mock_collection.find_one({"consent_id": "test_consent_id"})
)
def test_delete_user_consent(self):
def test_delete_all_user_consents(self):
self.consent_db.collection = self.mock_collection
self.consent_db.save_consent("test_consent_id1", self.consent)
self.consent_db.save_consent("test_consent_id2", self.consent)
self.consent_db.delete_user_consent("test_user_id")
self.consent_db.save_consent("test_consent_id1", self.consent1)
self.consent_db.save_consent("test_consent_id2", self.consent1)
self.consent_db.delete_all_user_consents("test_user_id1")
self.assertEqual(
self.mock_collection.count_documents({"user_id": "test_user_id"}), 0
self.mock_collection.count_documents({"user_id": "test_user_id1"}), 0
)
def test_delete_single_user_consent_consent_exists(self):
self.consent_db.collection = self.mock_collection
self.consent_db.save_consent("test_consent_id1", self.consent1)
self.consent_db.save_consent("test_consent_id2", self.consent1)
self.assertEqual(
self.mock_collection.count_documents({"user_id": "test_user_id1"}), 2
)
deleted_count = self.consent_db.delete_user_consent("test_consent_id2")
self.assertEqual(deleted_count, 1)
self.assertEqual(
self.mock_collection.count_documents({"user_id": "test_user_id1"}), 1
)
self.assertEqual(
self.mock_collection.count_documents({"consent_id": "test_consent_id1"}), 1
)
self.assertEqual(
self.mock_collection.count_documents({"consent_id": "test_consent_id2"}), 0
)
def test_delete_single_user_consent_consent_doesnt_exist(self):
self.consent_db.collection = self.mock_collection
self.consent_db.save_consent("test_consent_id1", self.consent1)
self.assertEqual(
self.mock_collection.count_documents({"user_id": "test_user_id1"}), 1
)
deleted_count = self.consent_db.delete_user_consent("test_consent_fake_id")
self.assertEqual(deleted_count, 0)
self.assertEqual(
self.mock_collection.count_documents({"user_id": "test_user_id1"}), 1
)
def test_get_all_user_consents(self):
self.consent_db.collection = self.mock_collection
# consent1 and consent2 belong to differnet users
self.consent_db.save_consent("test_consent_id1", self.consent1)
self.consent_db.save_consent("test_consent_id2", self.consent1)
self.consent_db.save_consent("test_consent_id3", self.consent2)
obtained_consents = self.consent_db.get_all_user_consents("test_user_id1")
self.assertEqual(len(obtained_consents), 2)
obtained_consents = self.consent_db.get_all_user_consents("test_user_id2")
self.assertEqual(len(obtained_consents), 1)
from typing import Any
from typing import Any, 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
from sqlalchemy.engine import Engine
from sqlalchemy.orm.session import Session
from sqlalchemy import MetaData
from perun.utils.ConfigStore import ConfigStore
......@@ -160,6 +160,17 @@ class UserManager:
def _get_ssp_sessions_collection(self) -> Collection:
return self.get_mongo_db_collection("ssp_database")
def sub_to_user_id(self, sub: str, issuer: str) -> Optional[str]:
"""
Get Perun user ID using user's 'sub' attribute
:param sub: Perun user's subject attribute
:return: Perun user ID
"""
if sub and issuer:
user = self._ADAPTERS_MANAGER.get_perun_user(idp_id=issuer, uids=[sub])
if user:
return str(user.id)
def logout(
self,
user_id: str = None,
......@@ -183,7 +194,6 @@ class UserManager:
"logout."
)
return
user_attrs = self._ADAPTERS_MANAGER.get_user_attributes(
int(user_id), [self._SUBJECT_ATTRIBUTE]
)
......
......@@ -20,7 +20,7 @@ class CustomRPHandler(RPHandler):
def backchannel_logout(
self, client: RP, request="", request_args=None
) -> Tuple[str, Union[str, None]]:
) -> Tuple[str, Union[str, None], str]:
"""
Custom method for backchannel logout. It supports backchannel logout
only with 'sub' information. Stock idpy-oidc backchannel_logout method
......@@ -62,4 +62,5 @@ class CustomRPHandler(RPHandler):
return (
req[verified_claim_name("logout_token")].get("sub"),
req[verified_claim_name("logout_token")].get("sid"),
_context.get("issuer"),
)
......@@ -29,6 +29,10 @@ class ConsentDB:
return consent
def get_all_user_consents(self, user_id: str):
consents_cursor = self.collection.find({"user_id": user_id})
return list(consents_cursor)
def delete_consent(self, consent_id: str):
self.collection.delete_one({"consent_id": consent_id})
......@@ -44,9 +48,13 @@ class ConsentDB:
self.collection.insert_one(data)
def delete_user_consent(self, user_id):
def delete_all_user_consents(self, user_id: str):
self.collection.delete_many({"user_id": user_id})
def delete_user_consent(self, consent_id: str) -> int:
result = self.collection.delete_one({"consent_id": consent_id})
return result.deleted_count
@staticmethod
def get_database(cfg, cfg_db_name: str) -> Collection:
client = MongoClient(cfg[cfg_db_name]["connection_string"])
......
......@@ -2,8 +2,8 @@ import json
import logging
from perun.utils.consent_framework.consent import Consent
from perun.utils.consent_framework.consent_request import ConsentRequest
from perun.utils.consent_framework.consent_db import ConsentDB
from perun.utils.consent_framework.consent_request import ConsentRequest
from perun.utils.consent_framework.consent_request_db import ConsentRequestDB
logger = logging.getLogger(__name__)
......@@ -28,6 +28,9 @@ class ConsentManager(object):
logger.debug("No consented attributes for id: '%s'", consent_id)
return
def fetch_all_user_consents(self, user_id: str):
return self.consent_db.get_all_user_consents(user_id)
def save_consent_request(self, jwt):
request = jwt
......@@ -53,5 +56,8 @@ class ConsentManager(object):
def save_consent(self, consent_id: str, consent: Consent):
self.consent_db.save_consent(consent_id, consent)
def delete_user_consent(self, user_id):
self.consent_db.delete_user_consent(user_id)
def delete_all_user_consents(self, user_id):
self.consent_db.delete_all_user_consents(user_id)
def delete_user_consent(self, consent_id) -> int:
return self.consent_db.delete_user_consent(consent_id)
......@@ -16,6 +16,7 @@ setup(
include_package_data=True,
packages=find_namespace_packages(include=["perun.*"]),
install_requires=[
"Authlib~=1.2",
"setuptools",
"PyYAML~=6.0",
"Flask~=2.2",
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment