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