diff --git a/README.md b/README.md index 1810c114a571461cb24264395767872b50f1acc0..dbde8630bf1579e99180cc7425540a82c64efd96 100644 --- a/README.md +++ b/README.md @@ -228,3 +228,29 @@ that the input data is passed in binary form as `.tar` file in the request. - `HTTP OK [200]` indicating a successful operation, the body of the response includes either the ban information as a JSON if it exists or an empty JSON `{}` if a ban with given ID doesn't exist + +### Heuristic page + +Provides information about user authentication events gathered by AuthEventLogging microservice, to confirm theirs identity. + +<br></br> +**Endpoint:** `/HeuristicGetID` + +**Description:** Used to gather ID of searched user + +**Result:** + +- `HHTP OK [200]` indicating successfull load of search page + +<br></br> +**Endpoint:** `/GetHeuristic` + +**Method:** `GET` + +**Description:** Used for showing gathered information about past athentications of user, and showing statistics based on that data. + +**Input arguments:** ID of searched user + +**Result:** + +- `HHTP OK [200]` indicating successfull load of show page diff --git a/config_templates/perun.proxygui.yaml b/config_templates/perun.proxygui.yaml index cce7329746c3f4b5aa73e670b0c23611ca0b0c1a..71ed2df21482209b4255256d468c0a99920f4d3d 100644 --- a/config_templates/perun.proxygui.yaml +++ b/config_templates/perun.proxygui.yaml @@ -124,6 +124,14 @@ 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 jwt_nonce_database: # REQUIRED connection_string: connection_string database_name: database_name @@ -140,3 +148,5 @@ oidc_provider: # REQUIRED for OAuth2/OIDC protection of some endpoints post_logout_redirect_uris: - uri1 - uri2 +auth_event_logging: # REQUIRED + logging_db: postgresql+psycopg2://user:password@hostname/database_name diff --git a/perun/proxygui/api/heuristic_api.py b/perun/proxygui/api/heuristic_api.py new file mode 100644 index 0000000000000000000000000000000000000000..b9789069dddc3d077b9a9f772874f72b8de1618c --- /dev/null +++ b/perun/proxygui/api/heuristic_api.py @@ -0,0 +1,131 @@ +from satosacontrib.perun.utils.AuthEventLoggingDbModels import ( + AuthEventLoggingTable, + UserAgentTable, + RequestedAcrsTable, +) +from sqlalchemy import create_engine, MetaData, distinct +from user_agents import parse + + +class AuthEventLoggingQuerries: + def __init__(self, cfg): + self.logging_db = cfg["auth_event_logging"]["logging_db"] + + def get_last_5_cities(self, user): + engine = create_engine(self.logging_db) + with engine.connect() as cnxn: + meta_data = MetaData(bind=engine) + MetaData.reflect(meta_data) + + auth_event_logging = AuthEventLoggingTable().__table__ + querry = ( + auth_event_logging.select( + [distinct(auth_event_logging.c.geolocation_city)] + ) + .order_by(auth_event_logging.c.day.desc()) + .where(auth_event_logging.columns.user == user) + .limit(5) + ) + response = cnxn.execute(querry).fetchall() + + result = [r._asdict() for r in response] + cities = [] + for item in result: + cities.append(item["geolocation_city"]) + + return cities + + def get_last_100_times(self, user): + engine = create_engine(self.logging_db) + with engine.connect() as cnxn: + meta_data = MetaData(bind=engine) + MetaData.reflect(meta_data) + + auth_event_logging = AuthEventLoggingTable().__table__ + querry = ( + auth_event_logging.select(auth_event_logging.c.day) + .order_by(auth_event_logging.c.day.desc()) + .where(auth_event_logging.c.user == user) + .limit(100) + ) + response = cnxn.execute(querry).fetchall() + + result = [r._asdict() for r in response] + times = {} + + # Dividing timestamps into dict by half-hours -> {'7:30': 2, '10:00': 1, ...} + for item in result: + hour = str(item["day"].hour) + minutes = item["day"].minute + if minutes >= 30: + minutes = str(3) + else: + minutes = str(0) + + if hour + ":" + minutes + "0" not in times: + times[hour + ":" + minutes + "0"] = 1 + else: + times[hour + ":" + minutes + "0"] += 1 + + return times + + def get_ids_from_foreign_table(self, cnxn, user, auth_table, requested_col, limit): + querry = ( + auth_table.select(requested_col) + .where(auth_table.c.user == user) + .limit(limit) + ) + return cnxn.execute(querry).fetchall() + + def get_unique_user_agents(self, user): + engine = create_engine(self.logging_db) + with engine.connect() as cnxn: + meta_data = MetaData(bind=engine) + MetaData.reflect(meta_data) + + auth_event_logging = AuthEventLoggingTable().__table__ + user_agent = UserAgentTable().__table__ + + ids = self.get_ids_from_foreign_table( + cnxn, user, auth_event_logging, auth_event_logging.c.user_agent_id, 200 + ) + + querry = user_agent.select([distinct(user_agent.c.value)]).where( + user_agent.c.id in ids + ) + response = cnxn.execute(querry).fetchall() + result = [r._asdict() for r in response] + agents = [] + + for item in result: + agents.append(parse(item["value"])) + + return agents + + def get_unique_arcs(self, user): + engine = create_engine(self.logging_db) + with engine.connect() as cnxn: + meta_data = MetaData(bind=engine) + MetaData.reflect(meta_data) + + auth_event_logging = AuthEventLoggingTable().__table__ + req_arcs = RequestedAcrsTable().__table__ + + ids = self.get_ids_from_foreign_table( + cnxn, + user, + auth_event_logging, + auth_event_logging.c.requested_arcs_id, + 100, + ) + querry = req_arcs.select([distinct(req_arcs.c.value)]).where( + req_arcs.c.id in ids + ) + response = cnxn.execute(querry).fetchall() + + result = [r._asdict() for r in response] + arcs = [] + for item in result: + arcs.append(item["value"]) + + return arcs diff --git a/perun/proxygui/app.py b/perun/proxygui/app.py index ccafe43d7ccb33ed5a4d23fd604b93152bdf442c..35d6afac6734cbe6fb98f39ca686e1e667615f74 100644 --- a/perun/proxygui/app.py +++ b/perun/proxygui/app.py @@ -24,6 +24,7 @@ from perun.proxygui.oauth import ( ) from perun.utils.CustomRPHandler import CustomRPHandler + PROXYGUI_CFG = "perun.proxygui.yaml" BACKCHANNEL_LOGOUT_CFG = "backchannel-logout.yaml" @@ -44,6 +45,7 @@ def get_config_path(filename: str, required=True) -> Optional[str]: def get_config(filename=PROXYGUI_CFG, required=True) -> dict: cfg_path = get_config_path(filename, required) + print(cfg_path) if not cfg_path: return {} with open( @@ -134,6 +136,7 @@ def get_flask_app(cfg): 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["oidc_provider"] configure_resource_protector(oauth_cfg) diff --git a/perun/proxygui/gui/gui.py b/perun/proxygui/gui/gui.py index 89f8ddd69ad3a1eb917b797f22ed8f08b9687ec9..a93387dc95e1d2d911c8a389c32b53db14d378c5 100644 --- a/perun/proxygui/gui/gui.py +++ b/perun/proxygui/gui/gui.py @@ -10,6 +10,7 @@ from flask_pyoidc.user_session import UserSession from perun.proxygui.jwt import JWTService from perun.proxygui.user_manager import UserManager +from perun.proxygui.api.heuristic_api import AuthEventLoggingQuerries from perun.utils.consent_framework.consent_manager import ConsentManager @@ -23,11 +24,13 @@ def ignore_claims(ignored_claims, claims): return result +# def construct_gui_blueprint(cfg, auth): 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) + auth_event = AuthEventLoggingQuerries(cfg) REDIRECT_URL = cfg["redirect_url"] COLOR = cfg["bootstrap_color"] @@ -157,4 +160,36 @@ def construct_gui_blueprint(cfg, auth): send_mfa_reset_emails=url_for("gui.send_mfa_reset_emails"), ) + @auth.oidc_auth(OIDC_CFG["provider_name"]) + @gui.route("/HeuristicGetID") + def heuristic_get_id(): + return render_template( + "HeuristicData.html", + redirect_url=REDIRECT_URL, + bootstrap_color=COLOR, + selected=False, + ) + + @auth.oidc_auth(OIDC_CFG["provider_name"]) + @gui.route("/GetHeuristic", methods=["GET"]) + def get_heuristic(): + user_id = request.args.get("user") + """ + Database selects from auth_event_logging_microservice + e.g. + data = get_data(user_id) + """ + + return render_template( + "HeuristicData.html", + redirect_url=REDIRECT_URL, + bootstrap_color=COLOR, + selected=True, + user=user_id, + last_5_cities=auth_event.get_last_5_cities(user_id), + last_100_times=auth_event.get_last_100_times(user_id), + user_agents=auth_event.get_unique_user_agents(user_id), + arcs=auth_event.get_unique_arcs(user_id), + ) + return gui diff --git a/perun/proxygui/gui/templates/HeuristicData.html b/perun/proxygui/gui/templates/HeuristicData.html new file mode 100644 index 0000000000000000000000000000000000000000..0407eb4b1a9bbf83ba318ddb8db92ce4b47c64ea --- /dev/null +++ b/perun/proxygui/gui/templates/HeuristicData.html @@ -0,0 +1,58 @@ +{% 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 %} + {% if selected %} + <div class="content"> + <br/> + <h3><span>{{ _("User ID: ") }}{{ user }}</span></h3> + + <div class="grid"> + <div class="grid__cell size--l--5-12 text-center"> + <h5>Last 5 cities connected from:</h5> + {% for city in last_5_cities %} + {{ city }}<br/> + + {% endfor %} + </div> + <div class="grid__cell size--l--5-12 text-center"> + <h5>Time activity</h5> + {{ last_100_times }} + + </div> + </div> + <div class="grid text-center"> + <div class="grid__cell size--l--5-12 text-center"> + <h5>User agents</h5> + {% for agent in user_agents %} + {{ agent }}<br/> + + {% endfor %} + </div> + <div class="grid__cell size--l--5-12 text-center"> + <h5>Unique requested arcs</h5> + {% for arc in arcs %} + {{ arc }}<br/> + + {% endfor %} + </div> + </div> + </div> + {% else %} + <div class="content"> + <br/> + <h3><span>{{ _("Specify an ID of user to gather data:") }}</span></h3> + <form action="/GetHeuristic" method="get"> + <input type="number" id="userID" name="user" min="1" required> + <input type="submit" value="Submit" name="submit"/> + </form> + </div> + {% endif %} + {% endblock %} + </div> + </div> + </div> +{% endblock %} \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index dde572d97f4427cc11403a6d148f75d95f02652f..ab3941115e93e2024d529fae8109e2b8b9239452 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,4 +1,3 @@ [metadata] version = 3.0.3 license_files = LICENSE - diff --git a/setup.py b/setup.py index 6b6c89691acb59d9b7dc1b976d9bcbdbba5a69ff..872056c49902f136c45a895321e642be18e0f2ad 100644 --- a/setup.py +++ b/setup.py @@ -29,6 +29,8 @@ setup( "pymongo~=4.4.1", "validators~=0.22.0", "idpyoidc~=2.0.0", + "satosacontrib.perun~=4.1", + "user-agents~=2.2.0", ], extras_require={ "kerberos": [