From 5df19b0c2e028cdbd2a04e7a84bc260746c0a0dd Mon Sep 17 00:00:00 2001 From: Arie Peterson <arie@greenhost.nl> Date: Thu, 23 Nov 2023 17:06:19 +0100 Subject: [PATCH] Track last recovery and login times --- backend/areas/users/user_service.py | 41 +++++++++++++++---- backend/areas/users/users.py | 32 ++++++++++++++- backend/areas/users/validation.py | 12 ++++++ backend/helpers/auth_guard.py | 18 +++++++- .../helmchart/templates/configmaps.yaml | 1 + 5 files changed, 94 insertions(+), 10 deletions(-) diff --git a/backend/areas/users/user_service.py b/backend/areas/users/user_service.py index eefee8e3..0783ef70 100644 --- a/backend/areas/users/user_service.py +++ b/backend/areas/users/user_service.py @@ -1,19 +1,26 @@ import ory_kratos_client +from ory_kratos_client.model.json_patch \ + import JsonPatch +from ory_kratos_client.model.json_patch_document \ + import JsonPatchDocument from ory_kratos_client.model.update_recovery_flow_body \ import UpdateRecoveryFlowBody from ory_kratos_client.api import frontend_api, identity_api -from config import KRATOS_ADMIN_URL +from datetime import datetime +import time + +from flask import current_app + +from config import KRATOS_ADMIN_URL from database import db from areas.apps import App, AppRole, AppsService from areas.roles import Role, RoleService from areas.tags import TagUser from helpers import KratosApi - -from flask import current_app - from helpers.error_handler import KratosError + kratos_admin_api_configuration = \ ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL, discard_unknown_keys=True) kratos_client = ory_kratos_client.ApiClient(kratos_admin_api_configuration) @@ -207,8 +214,8 @@ class UserService: db.session.delete(ar) db.session.commit() - @staticmethod - def post_multiple_users(data): + @classmethod + def post_multiple_users(cls, data): # check if data is array # for every item in array call Kratos created_users = [] @@ -220,7 +227,7 @@ class UserService: if not user_email: return try: - UserService.post_user(user_data) + cls.post_user(user_data) current_app.logger.info(f"Batch create user: {user_email}") created_users.append(user_email) except KratosError as err: @@ -251,6 +258,26 @@ class UserService: return {"success": success_response, "existing": existing_response, "failed": failed_response} + @staticmethod + def recovery_complete(userID): + # Current unix time. + now = int(datetime.today().timestamp()) + current_app.logger.info("Waiting a little while before setting last_recovery...") + time.sleep(0.5) + current_app.logger.info(f"Set last_recovery for {userID} to {now}") + patch = JsonPatch(op="replace", path="/metadata_admin/last_recovery", value=now) + patch_doc = JsonPatchDocument(value=[patch]) + kratos_identity_api.patch_identity(userID, json_patch_document=patch_doc) + + @staticmethod + def login_complete(userID): + # Current unix time. + now = int(datetime.today().timestamp()) + current_app.logger.info(f"Set last_login for {userID} to {now}") + patch = JsonPatch(op="replace", path="/metadata_admin/last_login", value=now) + patch_doc = JsonPatchDocument(value=[patch]) + kratos_identity_api.patch_identity(userID, json_patch_document=patch_doc) + @staticmethod def __insertAppRoleToUser(userId, userRes): apps = App.query.all() diff --git a/backend/areas/users/users.py b/backend/areas/users/users.py index b91f504d..9427a1d1 100644 --- a/backend/areas/users/users.py +++ b/backend/areas/users/users.py @@ -5,9 +5,9 @@ from flask_jwt_extended import get_jwt, jwt_required from areas import api_v1 from helpers import KratosApi -from helpers.auth_guard import admin_required +from helpers.auth_guard import admin_required, kratos_webhook -from .validation import schema, schema_multiple, schema_multi_edit +from .validation import schema, schema_multiple, schema_multi_edit, schema_recovery_complete from .user_service import UserService @@ -44,6 +44,34 @@ def reset_2fa(id): res = UserService.reset_2fa(id) return jsonify(res) +# This is supposed to be called by Kratos as a webhook after a user has +# successfully recovered their account. +@api_v1.route("/users/recovery_complete", methods=["POST"]) +@expects_json(schema_recovery_complete) +@kratos_webhook() +def recovery_complete(): + data = request.get_json() + try: + UserService.recovery_complete(data["user_id"]) + except Exception as e: + current_app.logger.warn(f"Exception in /users/recovery_complete: {e}") + raise + return jsonify(message="Updated last recovery time.") + +# This is supposed to be called by Kratos as a webhook after a user has +# successfully logged in to their account. +@api_v1.route("/users/login_complete", methods=["POST"]) +@expects_json(schema_recovery_complete) +@kratos_webhook() +def login_complete(): + data = request.get_json() + try: + UserService.login_complete(data["user_id"]) + except Exception as e: + current_app.logger.warn(f"Exception in /users/login_complete: {e}") + raise + return jsonify(message="Updated last login time.") + @api_v1.route("/users", methods=["POST"]) @jwt_required() @cross_origin() diff --git a/backend/areas/users/validation.py b/backend/areas/users/validation.py index 67b02cb1..6b50029b 100644 --- a/backend/areas/users/validation.py +++ b/backend/areas/users/validation.py @@ -94,3 +94,15 @@ schema_multi_edit = { } } } + +schema_recovery_complete = { + "type": "object", + "properties": { + "user_id": { + "type": "string", + "description": "Kratos ID of the user", + "minLength": 1, + }, + }, + "required": ["user_id"], +} diff --git a/backend/helpers/auth_guard.py b/backend/helpers/auth_guard.py index 36bbeeb1..b9dd9723 100644 --- a/backend/helpers/auth_guard.py +++ b/backend/helpers/auth_guard.py @@ -1,9 +1,11 @@ from functools import wraps +import os from areas.roles.role_service import RoleService +from helpers import Unauthorized +from flask import request from flask_jwt_extended import get_jwt, verify_jwt_in_request -from helpers import Unauthorized def admin_required(): @@ -22,3 +24,17 @@ def admin_required(): return decorator return wrapper + +def kratos_webhook(): + def wrapper(fn): + @wraps(fn) + def decorator(*args, **kwargs): + header = request.headers.get("Authorization") + if header is not None and header == os.environ.get("KRATOS_WEBHOOK_SECRET"): + return fn(*args, **kwargs) + else: + raise Unauthorized("This needs a valid api key.") + + return decorator + + return wrapper diff --git a/deployment/helmchart/templates/configmaps.yaml b/deployment/helmchart/templates/configmaps.yaml index 679c7004..571dbd6e 100644 --- a/deployment/helmchart/templates/configmaps.yaml +++ b/deployment/helmchart/templates/configmaps.yaml @@ -18,6 +18,7 @@ data: TOKEN_URL: {{ .Values.backend.oidc.tokenUrl }} KRATOS_PUBLIC_URL: {{ .Values.backend.kratos.publicUrl }} KRATOS_ADMIN_URL: {{ .Values.backend.kratos.adminUrl }} + KRATOS_WEBHOOK_SECRET: {{ .Values.backend.kratos.webhookSecret }} HYDRA_PUBLIC_URL: {{ .Values.backend.oidc.baseUrl }} HYDRA_ADMIN_URL: {{ .Values.backend.hydra.adminUrl }} DASHBOARD_URL: {{ .Values.backend.dashboardUrl }} -- GitLab