Repository: evennia/evennia

Issue:
Feature Request: More security-related info sent to console/logged by the network portal
#### Brief summary of issue / Description of requested feature:
As mentioned in #1375 , failed login attempts and temporary account lock-outs are not sent to console or logged, nor are connections that never reach the login authentication point.

#### Reasons for adding feature:
Security in the form of malicious activity detection/documentation, and getting a better idea of who/how many connections never result in any login attempt, failed or otherwise.

#### Error output / Expected result of feature
Similar to the entry for a successful login:
`2417-04-30T06:33:20+0000 [AMPProtocol,0,127.0.0.1] [..] Logged in: Golem(player 17) 8.282.39.12 (1 session(s) total)`
Perhaps something like:
`2417-04-30T06:33:20+0000 [AMPProtocol,0,127.0.0.1] [..] Failed to log in: Golem(player 17) 8.282.39.12 (1 session(s) total)`
and
`2417-04-30T06:33:20+0000 [AMPProtocol,0,127.0.0.1] [..] Connection established with 8.282.39.12 (1 session(s) from that address total)`

Finally, something similar for a temporary account lockout on multiple login failures:
`2417-04-30T06:33:20+0000 [AMPProtocol,0,127.0.0.1] [..] Locked out: Golem(player 17) 8.282.39.12 (5 failed login attempts total in last 2 minutes)`

#### Extra information, such as Evennia revision/repo/branch, operating system and ideas for how to solve / implement:

master/devel branch as appropriate, though this doesn't seem like a major change.

<bountysource-plugin>

---
Want to back this issue? **[Post a bounty on it!](https://www.bountysource.com/issues/47757081-feature-request-more-security-related-info-sent-to-console-logged-by-the-network-portal?utm_campaign=plugin&utm_content=tracker%2F522101&utm_medium=issues&utm_source=github)** We accept bounties via [Bountysource](https://www.bountysource.com/?utm_campaign=plugin&utm_content=tracker%2F522101&utm_medium=issues&utm_source=github).
</bountysource-plugin>


Discussion:
This sounds like a good idea .
This is definitely sorely needed. It would be great if we could capture the time, IP address and player object (whether accessed or denied), and effective character (where applicable) as CSV for things like:

* Login events
* Login timeouts
* Login failure
* Logout events
* Lockout events (too many failed password attempts)
* Ban/unban events
* Puppet events + target character name and dbref
* (optional, but useful for exploit detection and object auditing) All raw commands submitted by the players

This would make it easy to write a dashboard command (`@audit`) that allows wizard-class users to review and filter security events related to a player name, IP address, character, or even object (though aliases might skew this). Questions such as:

* "Who was puppeting this character on this date?"
* "Show me the unique IP addresses that have accessed this player account."
* "Show me all player accounts that this IP address is associated with."
* "Show me all the characters this player has ever puppeted, and when."
* "Show me all players who have puppeted this character."
* "I thought I banned this moron. Who the hell unbanned him, and when?"
* "Who stole my car, who has ever come into possession of it and who has it now?"

...could all be answered with a blind `grep` and some filtering or done completely in Python using iteration (slow, but assures cross-compatibility). This completely decouples security metrics from the core functionality-- no cycles are wasted serializing and deserializing lists and hitting DB to update attributes; resources are only consumed when the data is needed and the logs themselves could even be forwarded via filebeat/logstash for more advanced auditing pipelines and use cases.

I can handle writing such a command to handle the analytics but I don't understand the codebase well enough to enhance the logging beyond its current implementation.
This is a feature that should be present, but I would add some additional thoughts:

- Last good login address, last bad login address, number of unsuccessful attempts since last login, timestamp of last unsuccessful login, and timestamp of last successful login should all be database fields to monitor the "current" state. If this is cause for alarm, automated or otherwise, logs should be consulted for a historical account.
- The logs being in CSV is a poor choice. Logs like this should be in a syslog format so they are easily scannable by humans. Syslog is also a very standard format for such information, so there are plenty of libraries in Python and third-party services for parsing things from a Syslog format for automated analysis. These logs could be managed automatically with log rotation.
- Not using a database for historical accounts is for the following reason: Without code to automatically prune said database, this database after running through tests and over years would eventually need to be pruned and this is an administrative overhead that most users of Evennia are not prepared for. It is a reality of production software and services, but that should not be carried over into Evennia due to the difference in community.

As in most things with Evennia, within reason, features should be designed around the lowest level of technical understanding that we could put them at while still being useful. If more advanced users want more advanced behavior, that should be up to them to introduce. For example, with a syslog-formatted log file of security events, one could create a dashboard with Kibana and Logstash and even have automated alerts.
Since there appears to be some contention about it, I'm reiterating (like Blau does in the first sentence) a cross-reference to @tekmunkey's feature request #1375 which first brought up the missing IP logging. That one (the original post, the way I interpreted it) suggested storing said IP data on the Player (and possibly on the Character too), whereas this feature requests is similar except suggests using a log file rather than an in-database store. 
> The logs being in CSV is a poor choice. Logs like this should be in a syslog format so they are easily scannable by humans. 

Fair enough. I suggested csv for the sake of minimizing verbosity (only really need the 4 data points), but it wouldn't do anybody who looked at the log any good to just see a big list of nondescript...stuff.
Has there been any progress on this feature since PR #1390? Just trying to triage an old issue or two.
No, #1390 is closed but is instead handled by #1535 which has, unfortunately gone stale with no updates after the first round of PR feedback.

Resolving patch:
diff --git a/evennia/commands/default/account.py b/evennia/commands/default/account.py
--- a/evennia/commands/default/account.py
+++ b/evennia/commands/default/account.py
@@ -23,7 +23,7 @@
 import time
 from django.conf import settings
 from evennia.server.sessionhandler import SESSIONS
-from evennia.utils import utils, create, search, evtable
+from evennia.utils import utils, create, logger, search, evtable
 
 COMMAND_DEFAULT_CLASS = utils.class_from_module(settings.COMMAND_DEFAULT_CLASS)
 
@@ -171,6 +171,7 @@ def func(self):
             new_character.db.desc = "This is a character."
         self.msg("Created new character %s. Use |w@ic %s|n to enter the game as this character."
                  % (new_character.key, new_character.key))
+        logger.log_sec('Character Created: %s (Caller: %s, IP: %s).' % (new_character, account, self.session.address))
 
 
 class CmdCharDelete(COMMAND_DEFAULT_CLASS):
@@ -214,6 +215,7 @@ def _callback(caller, callback_prompt, result):
                     caller.db._playable_characters = [pc for pc in caller.db._playable_characters if pc != delobj]
                     delobj.delete()
                     self.msg("Character '%s' was permanently deleted." % key)
+                    logger.log_sec('Character Deleted: %s (Caller: %s, IP: %s).' % (key, account, self.session.address))
                 else:
                     self.msg("Deletion was aborted.")
                 del caller.ndb._char_to_delete
@@ -279,8 +281,10 @@ def func(self):
         try:
             account.puppet_object(session, new_character)
             account.db._last_puppet = new_character
+            logger.log_sec('Puppet Success: (Caller: %s, Target: %s, IP: %s).' % (account, new_character, self.session.address))
         except RuntimeError as exc:
             self.msg("|rYou cannot become |C%s|n: %s" % (new_character.name, exc))
+            logger.log_sec('Puppet Failed: %s (Caller: %s, Target: %s, IP: %s).' % (exc, account, new_character, self.session.address))
 
 
 # note that this is inheriting from MuxAccountLookCommand,
@@ -641,6 +645,7 @@ def func(self):
             account.set_password(newpass)
             account.save()
             self.msg("Password changed.")
+            logger.log_sec('Password Changed: %s (Caller: %s, IP: %s).' % (account, account, self.session.address))
 
 
 class CmdQuit(COMMAND_DEFAULT_CLASS):
diff --git a/evennia/commands/default/admin.py b/evennia/commands/default/admin.py
--- a/evennia/commands/default/admin.py
+++ b/evennia/commands/default/admin.py
@@ -9,7 +9,7 @@
 from django.conf import settings
 from evennia.server.sessionhandler import SESSIONS
 from evennia.server.models import ServerConfig
-from evennia.utils import evtable, search, class_from_module
+from evennia.utils import evtable, logger, search, class_from_module
 
 COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
 
@@ -95,6 +95,9 @@ def func(self):
         for session in boot_list:
             session.msg(feedback)
             session.account.disconnect_session_from_account(session)
+            
+        if pobj and boot_list:
+            logger.log_sec('Booted: %s (Reason: %s, Caller: %s, IP: %s).' % (pobj, reason, caller, self.session.address))
 
 
 # regex matching IP addresses with wildcards, eg. 233.122.4.*
@@ -203,6 +206,7 @@ def func(self):
         banlist.append(bantup)
         ServerConfig.objects.conf('server_bans', banlist)
         self.caller.msg("%s-Ban |w%s|n was added." % (typ, ban))
+        logger.log_sec('Banned %s: %s (Caller: %s, IP: %s).' % (typ, ban.strip(), self.caller, self.session.address))
 
 
 class CmdUnban(COMMAND_DEFAULT_CLASS):
@@ -246,8 +250,10 @@ def func(self):
             ban = banlist[num - 1]
             del banlist[num - 1]
             ServerConfig.objects.conf('server_bans', banlist)
+            value = " ".join([s for s in ban[:2]])
             self.caller.msg("Cleared ban %s: %s" %
-                            (num, " ".join([s for s in ban[:2]])))
+                            (num, value))
+            logger.log_sec('Unbanned: %s (Caller: %s, IP: %s).' % (value.strip(), self.caller, self.session.address))
 
 
 class CmdDelAccount(COMMAND_DEFAULT_CLASS):
@@ -317,6 +323,7 @@ def func(self):
         if reason:
             string += " Reason given:\n  '%s'" % reason
         account.msg(string)
+        logger.log_sec('Account Deleted: %s (Reason: %s, Caller: %s, IP: %s).' % (account, reason, caller, self.session.address))
         account.delete()
         self.msg("Account %s was successfully deleted." % uname)
 
@@ -445,6 +452,7 @@ def func(self):
         if account.character != caller:
             account.msg("%s has changed your password to '%s'." % (caller.name,
                                                                    newpass))
+        logger.log_sec('Password Changed: %s (Caller: %s, IP: %s).' % (account, caller, self.session.address))
 
 
 class CmdPerm(COMMAND_DEFAULT_CLASS):
@@ -526,6 +534,7 @@ def func(self):
                 else:
                     caller_result.append("\nPermission %s removed from %s (if they existed)." % (perm, obj.name))
                     target_result.append("\n%s revokes the permission(s) %s from you." % (caller.name, perm))
+                    logger.log_sec('Permissions Deleted: %s, %s (Caller: %s, IP: %s).' % (perm, obj, caller, self.session.address))
         else:
             # add a new permission
             permissions = obj.permissions.all()
@@ -547,6 +556,8 @@ def func(self):
                     caller_result.append("\nPermission '%s' given to %s (%s)." % (perm, obj.name, plystring))
                     target_result.append("\n%s gives you (%s, %s) the permission '%s'."
                                          % (caller.name, obj.name, plystring, perm))
+                    logger.log_sec('Permissions Added: %s, %s (Caller: %s, IP: %s).' % (obj, perm, caller, self.session.address))
+                    
         caller.msg("".join(caller_result).strip())
         if target_result:
             obj.msg("".join(target_result).strip())
diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py
--- a/evennia/commands/default/comms.py
+++ b/evennia/commands/default/comms.py
@@ -14,7 +14,7 @@
 from evennia.accounts import bots
 from evennia.comms.channelhandler import CHANNELHANDLER
 from evennia.locks.lockhandler import LockException
-from evennia.utils import create, utils, evtable
+from evennia.utils import create, logger, utils, evtable
 from evennia.utils.utils import make_iter, class_from_module
 
 COMMAND_DEFAULT_CLASS = class_from_module(settings.COMMAND_DEFAULT_CLASS)
@@ -368,6 +368,7 @@ def func(self):
         channel.delete()
         CHANNELHANDLER.update()
         self.msg("Channel '%s' was destroyed." % channel_key)
+        logger.log_sec('Channel Deleted: %s (Caller: %s, IP: %s).' % (channel_key, caller, self.session.address))
 
 
 class CmdCBoot(COMMAND_DEFAULT_CLASS):
@@ -433,6 +434,7 @@ def func(self):
         # disconnect account
         channel.disconnect(account)
         CHANNELHANDLER.update()
+        logger.log_sec('Channel Boot: %s (Channel: %s, Reason: %s, Caller: %s, IP: %s).' % (account, channel, reason, caller, self.session.address))
 
 
 class CmdCemit(COMMAND_DEFAULT_CLASS):


Test patch: