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

feat: sid logout support

parent f299519d
No related branches found
No related tags found
1 merge request!23feat: sid logout support
Pipeline #292702 passed
...@@ -125,6 +125,34 @@ Performs [OIDC Back-Channel Logout 1.0](https://openid.net/specs/openid-connect- ...@@ -125,6 +125,34 @@ Performs [OIDC Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-
**Method:** `POST` **Method:** `POST`
**Description**: The logout token **must** include an attribute `sub` containing
subject id
(id of the user to be logged out). It **may** also include `sid` containing an id of
a specific session of user identified by `sub`. In case the request contains `sid`
and the session with given `sid` exists and belongs to the user with provided `sub`, it
will be revoked, otherwise nothing happens. If **only** `sub` is provided, **all** the
sessions of the user with given `sub` will be revoked. If the user doesn't exist,
nothing happens.
Calling this endpoint revokes user's SSP sessions, Mitre tokens and SATOSA sessions.
Refresh tokens will stay intact as per [OIDC standard](https://openid.net/specs/openid-connect-backchannel-1_0.html#BCActions).
**Example logout token**:
```
{
"iss": "https://server.example.com",
"sub": "123456@user",
"sid": "2d1a...5264be", # OPTIONAL
"aud": "s6BhdRkqt3",
"iat": 1471566154,
"jti": "bWJq",
"events": {
"http://schemas.openid.net/event/backchannel-logout": {}
}
}
```
**Input **Input
arguments:** [OIDC Logout Token](https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken) arguments:** [OIDC Logout Token](https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken)
in the request body. in the request body.
...@@ -147,6 +175,9 @@ Provides management of Perun user bans. A banned user can not log in to the syst ...@@ -147,6 +175,9 @@ Provides management of Perun user bans. A banned user can not log in to the syst
bans the Perun users from logging in to the system. If the user is already banned, their ban is replaced with the latest bans the Perun users from logging in to the system. If the user is already banned, their ban is replaced with the latest
one (the one currently provided in the request). one (the one currently provided in the request).
Calling this endpoint revokes user's SSP sessions, Mitre tokens, SATOSA sessions and
refresh tokens.
**Example ban:** **Example ban:**
``` ```
......
...@@ -42,6 +42,7 @@ satosa_database: # REQUIRED ...@@ -42,6 +42,7 @@ satosa_database: # REQUIRED
collection_name: satosa_sessions_collection_name collection_name: satosa_sessions_collection_name
mitre_database: # REQUIRED mitre_database: # REQUIRED
connection_string: connection_string connection_string: connection_string
ssp_session_id_attribute: "urn:cesnet:proxyidp:attribute:sspSessionID"
global_cfg_filepath: global_cfg_filepath # REQUIRED: path to global microservice config from the satosacontrib.perun module global_cfg_filepath: global_cfg_filepath # REQUIRED: path to global microservice config from the satosacontrib.perun module
kerberos_service_name: HTTP/example.com kerberos_service_name: HTTP/example.com
perun_person_principal_names_attribute: "urn:perun:user:attribute-def:virt:eduPersonPrincipalNames:" # REQUIRED: name of person principal names attribute in Perun connector's attribute map perun_person_principal_names_attribute: "urn:perun:user:attribute-def:virt:eduPersonPrincipalNames:" # REQUIRED: name of person principal names attribute in Perun connector's attribute map
......
...@@ -34,10 +34,11 @@ def construct_backchannel_logout_api_blueprint(cfg, logout_cfg): ...@@ -34,10 +34,11 @@ def construct_backchannel_logout_api_blueprint(cfg, logout_cfg):
return Response(error_message, HTTPStatus.INTERNAL_SERVER_ERROR) return Response(error_message, HTTPStatus.INTERNAL_SERVER_ERROR)
else: else:
try: try:
sub = current_app.rp_handler.backchannel_logout( logout_params = current_app.rp_handler.backchannel_logout(
client, request_args=request.form client, request_args=request.form
) )
user_manager.logout(sub) user_manager.logout(*logout_params)
except Exception as ex: except Exception as ex:
error_message = ( error_message = (
f"Error happened while performing backchannel logout: '" f"{ex}'!" f"Error happened while performing backchannel logout: '" f"{ex}'!"
......
...@@ -101,7 +101,7 @@ def construct_ban_api_blueprint(cfg): ...@@ -101,7 +101,7 @@ def construct_ban_api_blueprint(cfg):
for user_id, ban in banned_users.items(): for user_id, ban in banned_users.items():
if not is_ban_in_db(int(ban["id"]), ban_collection): if not is_ban_in_db(int(ban["id"]), ban_collection):
USER_MANAGER.logout(user_id, True) USER_MANAGER.logout(user_id=user_id, include_refresh_tokens=True)
ban_collection.replace_one({"id": ban["id"]}, ban, upsert=True) ban_collection.replace_one({"id": ban["id"]}, ban, upsert=True)
logger.debug(f"User bans updated: {dumps(ban_collection.find())}") logger.debug(f"User bans updated: {dumps(ban_collection.find())}")
......
from typing import Any from typing import Any
import sqlalchemy import sqlalchemy
from perun.connector import AdaptersManager from perun.connector import AdaptersManager
from perun.connector import Logger from perun.connector import Logger
...@@ -31,15 +32,37 @@ class UserManager: ...@@ -31,15 +32,37 @@ class UserManager:
return client[database_name][collection_name] return client[database_name][collection_name]
def _revoke_ssp_sessions( def _revoke_ssp_sessions(
self, subject: str, ssp_sessions_collection: Collection self,
ssp_sessions_collection: Collection,
subject: str = None,
session_id: str = None,
) -> int: ) -> int:
result = ssp_sessions_collection.delete_many({"user": subject}) if session_id:
result = ssp_sessions_collection.delete_many(
{"sub": subject, "key": session_id}
)
elif subject:
result = ssp_sessions_collection.delete_many({"sub": subject})
else:
return 0
return result.deleted_count return result.deleted_count
def _revoke_satosa_grants( def _revoke_satosa_grants(
self, subject: str, satosa_sessions_collection: Collection self,
satosa_sessions_collection: Collection,
subject: str = None,
session_id: str = None,
) -> int: ) -> int:
result = satosa_sessions_collection.delete_many({"sub": subject}) 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:
return 0
return result.deleted_count return result.deleted_count
def _get_postgres_engine(self) -> Engine: def _get_postgres_engine(self) -> Engine:
...@@ -49,83 +72,78 @@ class UserManager: ...@@ -49,83 +72,78 @@ class UserManager:
return engine return engine
def _get_mitre_delete_statements( def _get_mitre_delete_statements(
self, user_id: str, engine: Engine, include_refresh_tokens=False self,
engine: Engine,
user_id: str = None,
session_id: str = None,
include_refresh_tokens=False,
) -> list[Any]: ) -> list[Any]:
meta_data = sqlalchemy.MetaData(bind=engine) meta_data = sqlalchemy.MetaData(bind=engine)
sqlalchemy.MetaData.reflect(meta_data) sqlalchemy.MetaData.reflect(meta_data)
session = Session(bind=engine) session = Session(bind=engine)
# tables holding general auth data
AUTH_HOLDER_TBL = meta_data.tables["authentication_holder"] AUTH_HOLDER_TBL = meta_data.tables["authentication_holder"]
SAVED_USER_AUTH_TBL = meta_data.tables["saved_user_auth"] SAVED_USER_AUTH_TBL = meta_data.tables["saved_user_auth"]
ACCESS_TOKEN_TBL = meta_data.tables["access_token"] matching_username = SAVED_USER_AUTH_TBL.c.name == user_id
delete_access_tokens_stmt = delete(ACCESS_TOKEN_TBL).where( if session_id:
ACCESS_TOKEN_TBL.c.auth_holder_id.in_( # if session id is present, we only delete tokens associated with a
session.query(AUTH_HOLDER_TBL.c.id).filter( # single specified session
AUTH_HOLDER_TBL.c.user_auth_id.in_( session_id_attr = (
session.query(SAVED_USER_AUTH_TBL.c.id).filter( self._cfg["mitre_database"]["ssp_session_id_attribute"]
SAVED_USER_AUTH_TBL.c.name == user_id or "urn:cesnet:proxyidp:attribute:sspSessionID"
)
)
)
) )
) matching_sid = SAVED_USER_AUTH_TBL.c.authentication_attributes.like(
f'%"{session_id_attr}":["{session_id}"]%'
AUTH_CODE_TBL = meta_data.tables["authorization_code"]
delete_authorization_codes_stmt = delete(AUTH_CODE_TBL).where(
AUTH_CODE_TBL.c.auth_holder_id.in_(
session.query(AUTH_HOLDER_TBL.c.id).filter(
AUTH_HOLDER_TBL.c.user_auth_id.in_(
session.query(SAVED_USER_AUTH_TBL.c.id).filter(
SAVED_USER_AUTH_TBL.c.name == user_id
)
)
)
) )
) user_auth = session.query(SAVED_USER_AUTH_TBL.c.id).filter(
matching_sid & matching_username
)
elif user_id:
# 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
)
else:
return []
# tables holding tokens
ACCESS_TOKEN_TBL = meta_data.tables["access_token"]
AUTH_CODE_TBL = meta_data.tables["authorization_code"]
DEVICE_CODE = meta_data.tables["device_code"] DEVICE_CODE = meta_data.tables["device_code"]
delete_device_codes_stmt = delete(DEVICE_CODE).where(
DEVICE_CODE.c.auth_holder_id.in_(
session.query(AUTH_HOLDER_TBL.c.id).filter(
AUTH_HOLDER_TBL.c.user_auth_id.in_(
session.query(SAVED_USER_AUTH_TBL.c.id).filter(
SAVED_USER_AUTH_TBL.c.name == user_id
)
)
)
)
)
statements = [ token_tables = [ACCESS_TOKEN_TBL, AUTH_CODE_TBL, DEVICE_CODE]
delete_access_tokens_stmt,
delete_authorization_codes_stmt,
delete_device_codes_stmt,
]
if include_refresh_tokens: if include_refresh_tokens:
REFRESH_TOKEN_TBL = meta_data.tables["refresh_token"] REFRESH_TOKEN_TBL = meta_data.tables["refresh_token"]
delete_refresh_tokens_stmt = delete(REFRESH_TOKEN_TBL).where( token_tables.append(REFRESH_TOKEN_TBL)
REFRESH_TOKEN_TBL.c.auth_holder_id.in_(
session.query(AUTH_HOLDER_TBL.c.id).filter( delete_statements = []
AUTH_HOLDER_TBL.c.user_auth_id.in_( for token_table in token_tables:
session.query(SAVED_USER_AUTH_TBL.c.id).filter( delete_statements.append(
SAVED_USER_AUTH_TBL.c.name == user_id delete(token_table).where(
) token_table.c.auth_holder_id.in_(
session.query(AUTH_HOLDER_TBL.c.id).filter(
AUTH_HOLDER_TBL.c.user_auth_id.in_(user_auth)
) )
) )
) )
) )
statements.append(delete_refresh_tokens_stmt)
return statements return delete_statements
def _delete_mitre_tokens(self, user_id: str, include_refresh_tokens=False) -> int: def _delete_mitre_tokens(
self,
user_id: str = None,
session_id: str = None,
include_refresh_tokens: bool = False,
) -> int:
deleted_mitre_tokens_count = 0 deleted_mitre_tokens_count = 0
engine = self._get_postgres_engine() engine = self._get_postgres_engine()
statements = self._get_mitre_delete_statements( statements = self._get_mitre_delete_statements(
user_id, engine, include_refresh_tokens engine, user_id, session_id, include_refresh_tokens
) )
for stmt in statements: for stmt in statements:
...@@ -140,25 +158,48 @@ class UserManager: ...@@ -140,25 +158,48 @@ class UserManager:
def _get_ssp_sessions_collection(self) -> Collection: def _get_ssp_sessions_collection(self) -> Collection:
return self.get_mongo_db_collection("ssp_database") return self.get_mongo_db_collection("ssp_database")
def logout(self, user_id: str, include_refresh_tokens=False) -> None: def logout(
self,
user_id: str = None,
session_id: str = None,
include_refresh_tokens: bool = False,
) -> 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
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
canceled as well
:return: Nothing
"""
if not user_id:
self.logger.info(
"No user id provided. Please, provide at least user id to perform "
"logout."
)
return
user_attrs = self._ADAPTERS_MANAGER.get_user_attributes( user_attrs = self._ADAPTERS_MANAGER.get_user_attributes(
int(user_id), [self._SUBJECT_ATTRIBUTE] int(user_id), [self._SUBJECT_ATTRIBUTE]
) )
subject_candidates = user_attrs.get(self._SUBJECT_ATTRIBUTE, []) subject_candidates = user_attrs.get(self._SUBJECT_ATTRIBUTE, [])
subject = subject_candidates[0] if subject_candidates else None subject = subject_candidates[0] if subject_candidates else None
ssp_sessions_collection = self._get_ssp_sessions_collection()
revoked_sessions_count = self._revoke_ssp_sessions(
subject, ssp_sessions_collection
)
satosa_sessions_collection = self._get_satosa_sessions_collection() satosa_sessions_collection = self._get_satosa_sessions_collection()
revoked_grants_count = self._revoke_satosa_grants( revoked_grants_count = self._revoke_satosa_grants(
subject, satosa_sessions_collection satosa_sessions_collection, subject, session_id
) )
deleted_tokens_count = self._delete_mitre_tokens( deleted_tokens_count = self._delete_mitre_tokens(
user_id, include_refresh_tokens user_id=user_id, include_refresh_tokens=include_refresh_tokens
)
ssp_sessions_collection = self._get_ssp_sessions_collection()
revoked_sessions_count = self._revoke_ssp_sessions(
ssp_sessions_collection, subject, session_id
) )
self.logger.info( self.logger.info(
......
from typing import Union, Tuple
from cryptojwt import as_unicode from cryptojwt import as_unicode
from idpyoidc import verified_claim_name from idpyoidc import verified_claim_name
from idpyoidc.client.oidc import RP from idpyoidc.client.oidc import RP
...@@ -16,7 +18,9 @@ class CustomRPHandler(RPHandler): ...@@ -16,7 +18,9 @@ class CustomRPHandler(RPHandler):
def __int__(self, *args, **kwargs): def __int__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def backchannel_logout(self, client: RP, request="", request_args=None) -> str: def backchannel_logout(
self, client: RP, request="", request_args=None
) -> Tuple[str, Union[str, None]]:
""" """
Custom method for backchannel logout. It supports backchannel logout Custom method for backchannel logout. It supports backchannel logout
only with 'sub' information. Stock idpy-oidc backchannel_logout method only with 'sub' information. Stock idpy-oidc backchannel_logout method
...@@ -26,7 +30,8 @@ class CustomRPHandler(RPHandler): ...@@ -26,7 +30,8 @@ class CustomRPHandler(RPHandler):
:param client: OIDC client implementation :param client: OIDC client implementation
:param request: URL encoded logout request :param request: URL encoded logout request
:param request_args: arguments from the URL encoded logout request :param request_args: arguments from the URL encoded logout request
:return: sub - OIDC subject id from validated JWT :return: (sub, sid) - tuple with OIDC subject id and session id from validated
JWT if session id was provided, otherwise None
""" """
if request_args: if request_args:
req = BackChannelLogoutRequest(**request_args) req = BackChannelLogoutRequest(**request_args)
...@@ -54,15 +59,7 @@ class CustomRPHandler(RPHandler): ...@@ -54,15 +59,7 @@ class CustomRPHandler(RPHandler):
else: else:
logger.debug("Request verified OK") logger.debug("Request verified OK")
# We're looking only for sub as sid logout is not supported in our return (
# implementation req[verified_claim_name("logout_token")].get("sub"),
sub = req[verified_claim_name("logout_token")].get("sub") req[verified_claim_name("logout_token")].get("sid"),
)
if not sub:
raise MessageException(
"This endpoint supports backchannel logout for all user's "
"sessions using only sub and it was not provided in the "
"logout request."
)
return sub
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment