From 2c9a13f370f0a24560e00c1d5628bea72addcb15 Mon Sep 17 00:00:00 2001
From: Dmitry Ivanov <ivadmi5@gmail.com>
Date: Thu, 30 Nov 2023 18:52:29 +0300
Subject: [PATCH] feat(audit): add authentication events

This patch adds two new events:
* auth_ok
* auth_fail, which may contain "verdict" describing user suspension.

Examples:

```json
{
  "id": "1.1.19",
  "message": "successfully authenticated user `guest`",
  "severity": "low",
  "time": "2023-11-30T19:02:10.708+0300",
  "title": "auth_ok",
  "user": "guest"
}
```

```json
{
  "id": "1.0.11",
  "message": "failed to authenticate user `borat`",
  "severity": "high",
  "time": "2023-11-30T18:58:48.635+0300",
  "title": "auth_failed",
  "user": "borat"
}
```
---
 src/lib.rs | 124 +++++++++++++++++++++++++++++++++++++----------------
 1 file changed, 86 insertions(+), 38 deletions(-)

diff --git a/src/lib.rs b/src/lib.rs
index 240cf5c487..3d1ddbe441 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -289,50 +289,98 @@ fn redirect_interactive_sql() {
 /// Sets a check for user exceeding maximum number of login attempts through `picodata connect`.
 /// Also see [`storage::PropertyName::MaxLoginAttempts`].
 fn set_login_attempts_check(storage: Clusterwide) {
-    use std::collections::hash_map::Entry;
+    enum Verdict {
+        AuthOk,
+        AuthFail,
+        UserBlocked,
+    }
+
+    // Determines the outcome of an authentication attempt.
+    let compute_auth_verdict = move |user: String, status: bool| {
+        use std::collections::hash_map::Entry;
 
-    // It's ok to loose this information during restart, so we keep it as a static.
-    static mut LOGIN_ATTEMPTS: OnceCell<HashMap<String, usize>> = OnceCell::new();
-    const ERROR: &str = "Maximum number of login attempts exceeded";
+        // It's ok to lose this information during restart, so we keep it as a static.
+        static mut LOGIN_ATTEMPTS: OnceCell<HashMap<String, usize>> = OnceCell::new();
+
+        // SAFETY: Accessing `USER_ATTEMPTS` is safe as it is only done from a single thread
+        let attempts = unsafe {
+            LOGIN_ATTEMPTS.get_or_init(HashMap::new);
+            LOGIN_ATTEMPTS.get_mut().expect("is initialized")
+        };
+
+        let max_login_attempts = || {
+            storage
+                .properties
+                .max_login_attempts()
+                .expect("accessing storage should not fail")
+        };
+
+        match attempts.entry(user) {
+            Entry::Occupied(e) if *e.get() >= max_login_attempts() => {
+                // The account is suspended until restart
+                Verdict::UserBlocked
+            }
+            Entry::Occupied(mut e) => {
+                if status {
+                    // Forget about previous failures
+                    e.remove();
+                    Verdict::AuthOk
+                } else {
+                    *e.get_mut() += 1;
+                    Verdict::AuthFail
+                }
+            }
+            Entry::Vacant(e) => {
+                if status {
+                    Verdict::AuthOk
+                } else {
+                    // Remember the failure, but don't raise an error yet
+                    e.insert(1);
+                    Verdict::AuthFail
+                }
+            }
+        }
+    };
 
     let lua = ::tarantool::lua_state();
     lua.exec_with(
         "box.session.on_auth(...)",
-        tlua::function3(
-            move |user: String, status: bool, lua: tlua::LuaState| unsafe {
-                // SAFETY: Accessing `USER_ATTEMPTS` is safe as it is only done from a single thread
-                LOGIN_ATTEMPTS.get_or_init(HashMap::new);
-                let attempts = LOGIN_ATTEMPTS.get_mut().expect("is initialized");
-
-                match attempts.entry(user) {
-                    Entry::Occupied(mut count) => {
-                        if *count.get()
-                            >= storage
-                                .properties
-                                .max_login_attempts()
-                                .expect("accessing storage should not fail")
-                        {
-                            // Raises an error instead of returning it as a function result.
-                            // This is the behavior required by `on_auth` trigger to drop the connection.
-                            // All the drop implementations are called, no need to clean anything up.
-                            tlua::ffi::lua_pushlstring(lua, ERROR.as_ptr() as _, ERROR.len());
-                            tlua::ffi::lua_error(lua);
-                            unreachable!();
-                        } else if status {
-                            // reset count on successful login
-                            count.remove();
-                        } else {
-                            *count.get_mut() += 1
-                        }
-                    }
-                    Entry::Vacant(count) => {
-                        if !status {
-                            count.insert(1);
-                        }
-                    }
+        tlua::function3(move |user: String, status: bool, lua: tlua::LuaState| {
+            const ERROR: &str = "Maximum number of login attempts exceeded";
+
+            match compute_auth_verdict(user.clone(), status) {
+                Verdict::AuthOk => {
+                    crate::audit!(
+                        message: "successfully authenticated user `{user}`",
+                        title: "auth_ok",
+                        severity: High,
+                        user: &user,
+                    );
+                }
+                Verdict::AuthFail => {
+                    crate::audit!(
+                        message: "failed to authenticate user `{user}`",
+                        title: "auth_fail",
+                        severity: High,
+                        user: &user,
+                    );
                 }
-            },
-        ),
+                Verdict::UserBlocked => {
+                    crate::audit!(
+                        message: "failed to authenticate user `{user}`",
+                        title: "auth_fail",
+                        severity: High,
+                        user: &user,
+                        verdict: format_args!("{ERROR}; user will be blocked indefinitely"),
+                    );
+
+                    // Raises an error instead of returning it as a function result.
+                    // This is the behavior required by `on_auth` trigger to drop the connection.
+                    // All the drop implementations are called, no need to clean anything up.
+                    tlua::error!(lua, "{}", ERROR);
+                }
+            }
+        }),
     )
     .expect("setting on auth trigger should not fail")
 }
-- 
GitLab