Index: /binary-improvements2/CommandExtensions/src/Commands/ListKnownPlayers.cs
===================================================================
--- /binary-improvements2/CommandExtensions/src/Commands/ListKnownPlayers.cs	(revision 403)
+++ /binary-improvements2/CommandExtensions/src/Commands/ListKnownPlayers.cs	(revision 404)
@@ -61,5 +61,5 @@
 					if (
 						(!onlineOnly || player.IsOnline)
-						&& (!notBannedOnly || !admTools.IsBanned (userId, out _, out _))
+						&& (!notBannedOnly || !admTools.Blacklist.IsBanned (userId, out _, out _))
 						&& (nameFilter.Length == 0 || player.Name.ContainsCaseInsensitive (nameFilter))
 					) {
Index: /binary-improvements2/WebServer/WebServer.csproj
===================================================================
--- /binary-improvements2/WebServer/WebServer.csproj	(revision 403)
+++ /binary-improvements2/WebServer/WebServer.csproj	(revision 404)
@@ -43,4 +43,8 @@
   </PropertyGroup>
   <ItemGroup>
+    <Reference Include="0Harmony, Version=2.10.0.0, Culture=neutral, PublicKeyToken=null">
+      <HintPath>..\7dtd-binaries\0Harmony.dll</HintPath>
+      <Private>False</Private>
+    </Reference>
     <Reference Include="Assembly-CSharp-firstpass, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null">
       <HintPath>..\7dtd-binaries\Assembly-CSharp-firstpass.dll</HintPath>
@@ -65,4 +69,8 @@
     <Reference Include="System.Xml, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
       <HintPath>..\7dtd-binaries\System.Xml.dll</HintPath>
+      <Private>False</Private>
+    </Reference>
+    <Reference Include="System.Xml.Linq, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
+      <HintPath>..\7dtd-binaries\System.Xml.Linq.dll</HintPath>
       <Private>False</Private>
     </Reference>
@@ -93,4 +101,5 @@
   </ItemGroup>
   <ItemGroup>
+    <Compile Include="src\Commands\CreateWebUser.cs" />
     <Compile Include="src\FileCache\AbstractCache.cs" />
     <Compile Include="src\FileCache\DirectAccess.cs" />
@@ -101,4 +110,8 @@
     <Compile Include="src\LiveData\Hostiles.cs" />
     <Compile Include="src\ModApi.cs" />
+    <Compile Include="src\Permissions\AdminWebModules.cs" />
+    <Compile Include="src\Permissions\AdminApiTokens.cs" />
+    <Compile Include="src\Permissions\AdminWebUsers.cs" />
+    <Compile Include="src\Permissions\RegisterModules.cs" />
     <Compile Include="src\UrlHandlers\ApiHandler.cs" />
     <Compile Include="src\UrlHandlers\SseHandler.cs" />
@@ -123,5 +136,4 @@
     <Compile Include="src\AssemblyInfo.cs" />
     <Compile Include="src\Commands\EnableOpenIDDebug.cs" />
-    <Compile Include="src\Commands\ReloadWebPermissions.cs" />
     <Compile Include="src\Commands\WebPermissionsCmd.cs" />
     <Compile Include="src\Commands\WebTokens.cs" />
@@ -144,5 +156,4 @@
     <Compile Include="src\WebConnection.cs" />
     <Compile Include="src\WebMod.cs" />
-    <Compile Include="src\WebPermissions.cs" />
     <Compile Include="src\WebUtils.cs" />
   </ItemGroup>
Index: /binary-improvements2/WebServer/src/Commands/CreateWebUser.cs
===================================================================
--- /binary-improvements2/WebServer/src/Commands/CreateWebUser.cs	(revision 404)
+++ /binary-improvements2/WebServer/src/Commands/CreateWebUser.cs	(revision 404)
@@ -0,0 +1,73 @@
+using System.Collections.Generic;
+using System.Text.RegularExpressions;
+using JetBrains.Annotations;
+using Platform.EOS;
+using Platform.Steam;
+using Webserver.Permissions;
+
+namespace Webserver.Commands {
+	[UsedImplicitly]
+	public class CreateWebUser : ConsoleCmdAbstract {
+		private static readonly Regex validNameTokenMatcher = new Regex (@"^\w+$");
+
+		public override string[] GetCommands () {
+			return new[] {"createwebuser"};
+		}
+
+		public override string GetDescription () {
+			return "Create a web dashboard user account";
+		}
+
+		public override string GetHelp () {
+			return ""; // TODO
+		}
+
+		public override int DefaultPermissionLevel => 1000;
+		public override bool IsExecuteOnClient => true;
+
+		public override void Execute (List<string> _params, CommandSenderInfo _senderInfo) {
+			// if (GameManager.IsDedicatedServer) {
+			// 	SdtdConsole.Instance.Output ("Command can only be executed on game clients or listen servers.");
+			// 	return;
+			// }
+			
+			// TODO
+			
+			if (_params.Count < 2) {
+				SdtdConsole.Instance.Output ($"Wrong number of arguments");
+				return;
+			}
+
+			string name = _params [0];
+			string pass = _params [1];
+			
+			if (string.IsNullOrEmpty (name)) {
+				SdtdConsole.Instance.Output ("Argument 'name' is empty.");
+				return;
+			}
+
+			if (!validNameTokenMatcher.IsMatch (name)) {
+				SdtdConsole.Instance.Output (
+					"Argument 'name' may only contain characters (A-Z, a-z), digits (0-9) and underscores (_).");
+				return;
+			}
+
+			if (string.IsNullOrEmpty (pass)) {
+				SdtdConsole.Instance.Output ("Argument 'password' is empty.");
+				return;
+			}
+
+			if (!validNameTokenMatcher.IsMatch (pass)) {
+				SdtdConsole.Instance.Output (
+					"Argument 'password' may only contain characters (A-Z, a-z), digits (0-9) and underscores (_).");
+				return;
+			}
+
+			AdminWebUsers.Instance.AddUser (name, pass, new UserIdentifierSteam (76561198066968172ul),
+				new UserIdentifierEos ("0002bc29a5624774b4b0dc27e60b974f"));
+
+			SdtdConsole.Instance.Output ($"User added");
+		}
+
+	}
+}
Index: nary-improvements2/WebServer/src/Commands/ReloadWebPermissions.cs
===================================================================
--- /binary-improvements2/WebServer/src/Commands/ReloadWebPermissions.cs	(revision 403)
+++ 	(revision )
@@ -1,20 +1,0 @@
-using System.Collections.Generic;
-using JetBrains.Annotations;
-
-namespace Webserver.Commands {
-	[UsedImplicitly]
-	public class ReloadWebPermissions : ConsoleCmdAbstract {
-		public override string GetDescription () {
-			return "force reload of web permissions file";
-		}
-
-		public override string[] GetCommands () {
-			return new[] {"reloadwebpermissions"};
-		}
-
-		public override void Execute (List<string> _params, CommandSenderInfo _senderInfo) {
-			WebPermissions.Instance.Load ();
-			SdtdConsole.Instance.Output ("Web permissions file reloaded");
-		}
-	}
-}
Index: /binary-improvements2/WebServer/src/Commands/WebPermissionsCmd.cs
===================================================================
--- /binary-improvements2/WebServer/src/Commands/WebPermissionsCmd.cs	(revision 403)
+++ /binary-improvements2/WebServer/src/Commands/WebPermissionsCmd.cs	(revision 404)
@@ -1,4 +1,5 @@
 using System.Collections.Generic;
 using JetBrains.Annotations;
+using Webserver.Permissions;
 
 namespace Webserver.Commands {
@@ -44,5 +45,5 @@
 			}
 
-			if (!WebPermissions.Instance.IsKnownModule (_params [1])) {
+			if (!AdminWebModules.Instance.IsKnownModule (_params [1])) {
 				SdtdConsole.Instance.Output ($"\"{_params [1]}\" is not a valid web function.");
 				return;
@@ -54,5 +55,5 @@
 			}
 
-			WebPermissions.Instance.AddModulePermission (_params [1], level);
+			AdminWebModules.Instance.AddModule (_params [1], level);
 			SdtdConsole.Instance.Output ($"{_params [1]} added with permission level of {level}.");
 		}
@@ -64,10 +65,10 @@
 			}
 
-			if (!WebPermissions.Instance.IsKnownModule (_params [1])) {
+			if (!AdminWebModules.Instance.IsKnownModule (_params [1])) {
 				SdtdConsole.Instance.Output ($"\"{_params [1]}\" is not a valid web function.");
 				return;
 			}
 
-			WebPermissions.Instance.RemoveModulePermission (_params [1]);
+			AdminWebModules.Instance.RemoveModule (_params [1]);
 			SdtdConsole.Instance.Output ($"{_params [1]} removed from permissions list.");
 		}
@@ -76,6 +77,10 @@
 			SdtdConsole.Instance.Output ("Defined web function permissions:");
 			SdtdConsole.Instance.Output ("  Level: Web function");
-			foreach (WebPermissions.WebModulePermission wmp in WebPermissions.Instance.GetModules ()) {
-				SdtdConsole.Instance.Output ($"  {wmp.permissionLevel,5}: {wmp.module}");
+			
+			List<AdminWebModules.WebModule> wmps = AdminWebModules.Instance.GetModules ();
+			for (int i = 0; i < wmps.Count; i++) {
+				AdminWebModules.WebModule wmp = wmps [i];
+				
+				SdtdConsole.Instance.Output ($"  {wmp.PermissionLevel,5}: {wmp.Name}");
 			}
 		}
Index: /binary-improvements2/WebServer/src/Commands/WebTokens.cs
===================================================================
--- /binary-improvements2/WebServer/src/Commands/WebTokens.cs	(revision 403)
+++ /binary-improvements2/WebServer/src/Commands/WebTokens.cs	(revision 404)
@@ -2,4 +2,5 @@
 using System.Text.RegularExpressions;
 using JetBrains.Annotations;
+using Webserver.Permissions;
 
 namespace Webserver.Commands {
@@ -19,6 +20,6 @@
 			return "Set/get webtoken permission levels. A level of 0 is maximum permission.\n" +
 			       "Usage:\n" +
-			       "   webtokens add <username> <usertoken> <level>\n" +
-			       "   webtokens remove <username>\n" +
+			       "   webtokens add <tokenname> <tokensecret> <level>\n" +
+			       "   webtokens remove <tokenname>\n" +
 			       "   webtokens list";
 		}
@@ -47,5 +48,5 @@
 
 			if (string.IsNullOrEmpty (_params [1])) {
-				SdtdConsole.Instance.Output ("Argument 'username' is empty.");
+				SdtdConsole.Instance.Output ("Argument 'tokenname' is empty.");
 				return;
 			}
@@ -53,10 +54,10 @@
 			if (!validNameTokenMatcher.IsMatch (_params [1])) {
 				SdtdConsole.Instance.Output (
-					"Argument 'username' may only contain characters (A-Z, a-z), digits (0-9) and underscores (_).");
+					"Argument 'tokenname' may only contain characters (A-Z, a-z), digits (0-9) and underscores (_).");
 				return;
 			}
 
 			if (string.IsNullOrEmpty (_params [2])) {
-				SdtdConsole.Instance.Output ("Argument 'usertoken' is empty.");
+				SdtdConsole.Instance.Output ("Argument 'tokensecret' is empty.");
 				return;
 			}
@@ -64,5 +65,5 @@
 			if (!validNameTokenMatcher.IsMatch (_params [2])) {
 				SdtdConsole.Instance.Output (
-					"Argument 'usertoken' may only contain characters (A-Z, a-z), digits (0-9) and underscores (_).");
+					"Argument 'tokensecret' may only contain characters (A-Z, a-z), digits (0-9) and underscores (_).");
 				return;
 			}
@@ -73,6 +74,6 @@
 			}
 
-			WebPermissions.Instance.AddAdmin (_params [1], _params [2], level);
-			SdtdConsole.Instance.Output ($"Web user with name={_params [1]} and password={_params [2]} added with permission level of {level}.");
+			AdminApiTokens.Instance.AddToken (_params [1], _params [2], level);
+			SdtdConsole.Instance.Output ($"Web API token with name={_params [1]} and secret={_params [2]} added with permission level of {level}.");
 		}
 
@@ -84,5 +85,5 @@
 
 			if (string.IsNullOrEmpty (_params [1])) {
-				SdtdConsole.Instance.Output ("Argument 'username' is empty.");
+				SdtdConsole.Instance.Output ("Argument 'tokenname' is empty.");
 				return;
 			}
@@ -90,17 +91,17 @@
 			if (!validNameTokenMatcher.IsMatch (_params [1])) {
 				SdtdConsole.Instance.Output (
-					"Argument 'username' may only contain characters (A-Z, a-z), digits (0-9) and underscores (_).");
+					"Argument 'tokenname' may only contain characters (A-Z, a-z), digits (0-9) and underscores (_).");
 				return;
 			}
 
-			WebPermissions.Instance.RemoveAdmin (_params [1]);
-			SdtdConsole.Instance.Output ($"{_params [1]} removed from web user permissions list.");
+			AdminApiTokens.Instance.RemoveToken (_params [1]);
+			SdtdConsole.Instance.Output ($"{_params [1]} removed from web API token permissions list.");
 		}
 
 		private void ExecuteList () {
-			SdtdConsole.Instance.Output ("Defined webuser permissions:");
-			SdtdConsole.Instance.Output ("  Level: Name / Token");
-			foreach (WebPermissions.AdminToken at in WebPermissions.Instance.GetAdmins ()) {
-				SdtdConsole.Instance.Output ($"  {at.permissionLevel,5}: {at.name} / {at.token}");
+			SdtdConsole.Instance.Output ("Defined web API token permissions:");
+			SdtdConsole.Instance.Output ("  Level: Name / Secret");
+			foreach ((string _, AdminApiTokens.ApiToken apiToken) in AdminApiTokens.Instance.GetTokens ()) {
+				SdtdConsole.Instance.Output ($"  {apiToken.PermissionLevel,5}: {apiToken.Name} / {apiToken.Secret}");
 			}
 		}
Index: /binary-improvements2/WebServer/src/ConnectionHandler.cs
===================================================================
--- /binary-improvements2/WebServer/src/ConnectionHandler.cs	(revision 403)
+++ /binary-improvements2/WebServer/src/ConnectionHandler.cs	(revision 404)
@@ -31,7 +31,7 @@
 		}
 
-		public WebConnection LogIn (PlatformUserIdentifierAbs _userId, IPAddress _ip) {
+		public WebConnection LogIn (IPAddress _ip, string _username, PlatformUserIdentifierAbs _userId, PlatformUserIdentifierAbs _crossUserId = null) {
 			string sessionId = Guid.NewGuid ().ToString ();
-			WebConnection con = new WebConnection (sessionId, _ip, _userId);
+			WebConnection con = new WebConnection (sessionId, _ip, _username, _userId, _crossUserId);
 			connections.Add (sessionId, con);
 			return con;
Index: /binary-improvements2/WebServer/src/ModApi.cs
===================================================================
--- /binary-improvements2/WebServer/src/ModApi.cs	(revision 403)
+++ /binary-improvements2/WebServer/src/ModApi.cs	(revision 404)
@@ -1,2 +1,4 @@
+using System.Reflection;
+using HarmonyLib;
 using JetBrains.Annotations;
 using Webserver.UrlHandlers;
@@ -12,4 +14,6 @@
 			ModEvents.GameShutdown.RegisterHandler (GameShutdown);
 			modInstance = _modInstance;
+
+			Harmony.CreateAndPatchAll (Assembly.GetExecutingAssembly ());
 		}
 
Index: /binary-improvements2/WebServer/src/Permissions/AdminApiTokens.cs
===================================================================
--- /binary-improvements2/WebServer/src/Permissions/AdminApiTokens.cs	(revision 404)
+++ /binary-improvements2/WebServer/src/Permissions/AdminApiTokens.cs	(revision 404)
@@ -0,0 +1,124 @@
+using System.Collections.Generic;
+using System.Xml;
+
+namespace Webserver.Permissions {
+	public class AdminApiTokens : AdminSectionAbs {
+		public static AdminApiTokens Instance { get; private set; }
+
+		public AdminApiTokens (AdminTools _parent) : base (_parent, "apitokens") {
+			Instance = this;
+		}
+
+		private readonly Dictionary<string, ApiToken> tokens = new CaseInsensitiveStringDictionary<ApiToken> ();
+
+
+#region IO
+
+		public override void Clear () {
+			tokens.Clear ();
+		}
+
+		protected override void ParseElement (XmlElement _childElement) {
+			if (ApiToken.TryParse (_childElement, out ApiToken apiToken)) {
+				tokens [apiToken.Name] = apiToken;
+			}
+		}
+
+		public override void Save (XmlElement _root) {
+			XmlElement adminTokensElem = _root.AddXmlElement (SectionTypeName);
+			adminTokensElem.AddXmlComment (" <token name=\"adminuser1\" secret=\"supersecrettoken\" permission_level=\"0\" /> ");
+
+			foreach ((string _, ApiToken apiToken) in tokens) {
+				apiToken.ToXml (adminTokensElem);
+			}
+		}
+
+#endregion
+
+
+#region Runtime interaction
+
+		public void AddToken (string _name, string _secret, int _permissionLevel) {
+			ApiToken apiToken = new ApiToken (_name, _secret, _permissionLevel);
+			tokens [_name] = apiToken;
+			Parent.Save ();
+		}
+
+		public bool RemoveToken (string _name) {
+			bool removed = tokens.Remove (_name);
+			if (removed) {
+				Parent.Save ();
+			}
+
+			return removed;
+		}
+
+		public Dictionary<string, ApiToken> GetTokens () {
+			return tokens;
+		}
+
+#endregion
+
+		public readonly struct ApiToken {
+			public readonly string Name;
+			public readonly string Secret;
+			public readonly int PermissionLevel;
+
+			public ApiToken (string _name, string _secret, int _permissionLevel) {
+				Name = _name;
+				Secret = _secret;
+				PermissionLevel = _permissionLevel;
+			}
+			
+			public void ToXml (XmlElement _parent) {
+				_parent.AddXmlElement ("token")
+					.SetAttrib ("name", Name)
+					.SetAttrib ("secret", Secret)
+					.SetAttrib ("permission_level", PermissionLevel.ToString ());
+			}
+
+			public static bool TryParse (XmlElement _element, out ApiToken _result) {
+				_result = default;
+
+
+				if (!_element.TryGetAttribute ("name", out string name)) {
+					Log.Warning ($"[Web] [Perms] Ignoring apitoken-entry because of missing 'name' attribute: {_element.OuterXml}");
+					return false;
+				}
+
+				if (!_element.TryGetAttribute ("secret", out string secret)) {
+					Log.Warning ($"[Web] [Perms] Ignoring apitoken-entry because of missing 'secret' attribute: {_element.OuterXml}");
+					return false;
+				}
+
+				if (!_element.TryGetAttribute ("permission_level", out string permissionLevelString)) {
+					Log.Warning ($"[Web] [Perms] Ignoring apitoken-entry because of missing 'permission_level' attribute: {_element.OuterXml}");
+					return false;
+				}
+
+				if (!int.TryParse (permissionLevelString, out int permissionLevel)) {
+					Log.Warning (
+						$"[Web] [Perms] Ignoring apitoken-entry because of invalid (non-numeric) value for 'permission_level' attribute: {_element.OuterXml}");
+					return false;
+				}
+
+				
+				_result = new ApiToken (name, secret, permissionLevel);
+				return true;
+			}
+		}
+
+
+#region Specials
+
+		public int GetPermissionLevel (string _name, string _secret) {
+			if (tokens.TryGetValue (_name, out ApiToken apiToken) && apiToken.Secret == _secret) {
+				return apiToken.PermissionLevel;
+			}
+
+			return int.MaxValue;
+		}
+
+#endregion
+	}
+}
Index: /binary-improvements2/WebServer/src/Permissions/AdminWebModules.cs
===================================================================
--- /binary-improvements2/WebServer/src/Permissions/AdminWebModules.cs	(revision 404)
+++ /binary-improvements2/WebServer/src/Permissions/AdminWebModules.cs	(revision 404)
@@ -0,0 +1,176 @@
+using System.Collections.Generic;
+using System.Xml;
+
+namespace Webserver.Permissions {
+	public class AdminWebModules : AdminSectionAbs {
+		public static AdminWebModules Instance { get; private set; }
+
+		public AdminWebModules (AdminTools _parent) : base (_parent, "webmodules") {
+			Instance = this;
+		}
+
+		private readonly Dictionary<string, WebModule> modules = new CaseInsensitiveStringDictionary<WebModule> ();
+
+
+#region IO
+
+		public override void Clear () {
+			modules.Clear ();
+		}
+
+		protected override void ParseElement (XmlElement _childElement) {
+			if (WebModule.TryParse (_childElement, out WebModule webModule)) {
+				modules [webModule.Name] = webModule;
+			}
+		}
+
+		public override void Save (XmlElement _root) {
+			XmlElement modulesElement = _root.AddXmlElement (SectionTypeName);
+			// modulesElement.AddXmlComment (" <module name=\"adminuser1\" secret=\"supersecrettoken\" permission_level=\"0\" /> ");
+
+			foreach ((string _, WebModule module) in modules) {
+				module.ToXml (modulesElement);
+			}
+		}
+
+#endregion
+
+
+#region Runtime interaction
+
+
+		public void AddModule (string _module, int _permissionLevel) {
+			WebModule p = new WebModule (_module, _permissionLevel);
+			lock (this) {
+				allModulesList.Clear ();
+				
+				modules [_module] = p;
+				Parent.Save ();
+			}
+		}
+
+		public bool RemoveModule (string _module) {
+			lock (this) {
+				allModulesList.Clear ();
+				
+				bool removed = modules.Remove (_module);
+				if (removed) {
+					Parent.Save ();
+				}
+
+				return removed;
+			}
+		}
+
+		public List<WebModule> GetModules () {
+			lock (this) {
+				if (allModulesList.Count != 0) {
+					return allModulesList;
+				}
+
+				foreach ((string moduleName, WebModule moduleDefaultPerm) in knownModules) {
+					allModulesList.Add (modules.TryGetValue (moduleName, out WebModule modulePermission)
+						? modulePermission
+						: moduleDefaultPerm);
+				}
+
+				return allModulesList;
+			}
+		}
+
+#endregion
+
+		public readonly struct WebModule {
+			public readonly string Name;
+			public readonly int PermissionLevel;
+
+			public WebModule (string _name, int _permissionLevel) {
+				Name = _name;
+				PermissionLevel = _permissionLevel;
+			}
+			
+			public void ToXml (XmlElement _parent) {
+				_parent.AddXmlElement ("module")
+						.SetAttrib ("name", Name)
+						.SetAttrib ("permission_level", PermissionLevel.ToString ());
+			}
+
+			public static bool TryParse (XmlElement _element, out WebModule _result) {
+				_result = default;
+
+
+				if (!_element.TryGetAttribute ("name", out string name)) {
+					Log.Warning ($"[Web] [Perms] Ignoring module-entry because of missing 'name' attribute: {_element.OuterXml}");
+					return false;
+				}
+
+				if (!_element.TryGetAttribute ("permission_level", out string permissionLevelString)) {
+					Log.Warning ($"[Web] [Perms] Ignoring module-entry because of missing 'permission_level' attribute: {_element.OuterXml}");
+					return false;
+				}
+
+				if (!int.TryParse (permissionLevelString, out int permissionLevel)) {
+					Log.Warning (
+						$"[Web] [Perms] Ignoring module-entry because of invalid (non-numeric) value for 'permission_level' attribute: {_element.OuterXml}");
+					return false;
+				}
+				
+				_result = new WebModule (name, permissionLevel);
+				return true;
+			}
+		}
+
+
+#region Specials
+
+		/// <summary>
+		/// Contains all registered modules and their default permission
+		/// </summary>
+		private readonly Dictionary<string, WebModule> knownModules = new CaseInsensitiveStringDictionary<WebModule> ();
+
+		/// <summary>
+		/// Public list of all modules, both those with custom permissions as well as those that do not with their default permission
+		/// </summary>
+		private readonly List<WebModule> allModulesList = new List<WebModule> ();
+
+		public void AddKnownModule (string _module, int _defaultPermission) {
+			if (string.IsNullOrEmpty (_module)) {
+				return;
+			}
+
+			WebModule p = new WebModule (_module, _defaultPermission);
+
+			lock (this) {
+				allModulesList.Clear ();
+				knownModules [_module] = p;
+			}
+		}
+
+		public bool IsKnownModule (string _module) {
+			if (string.IsNullOrEmpty (_module)) {
+				return false;
+			}
+
+			lock (this) {
+				return knownModules.ContainsKey (_module);
+			}
+		}
+
+		public bool ModuleAllowedWithLevel (string _module, int _level) {
+			WebModule permInfo = GetModule (_module);
+			return permInfo.PermissionLevel >= _level;
+		}
+
+		public WebModule GetModule (string _module) {
+			if (modules.TryGetValue (_module, out WebModule result)) {
+				return result;
+			}
+
+			return knownModules.TryGetValue (_module, out result) ? result : defaultModulePermission;
+		}
+
+		private readonly WebModule defaultModulePermission = new WebModule ("", 0);
+		
+#endregion
+	}
+}
Index: /binary-improvements2/WebServer/src/Permissions/AdminWebUsers.cs
===================================================================
--- /binary-improvements2/WebServer/src/Permissions/AdminWebUsers.cs	(revision 404)
+++ /binary-improvements2/WebServer/src/Permissions/AdminWebUsers.cs	(revision 404)
@@ -0,0 +1,149 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Xml;
+
+namespace Webserver.Permissions {
+	public class AdminWebUsers : AdminSectionAbs {
+		public static AdminWebUsers Instance { get; private set; }
+
+		public AdminWebUsers (AdminTools _parent) : base (_parent, "webusers") {
+			Instance = this;
+		}
+
+		private readonly Dictionary<string, WebUser> users = new CaseInsensitiveStringDictionary<WebUser> ();
+
+
+#region IO
+
+		public override void Clear () {
+			users.Clear ();
+		}
+
+		protected override void ParseElement (XmlElement _childElement) {
+			if (WebUser.TryParse (_childElement, out WebUser webUser)) {
+				users [webUser.Name] = webUser;
+			}
+		}
+
+		public override void Save (XmlElement _root) {
+			XmlElement usersElement = _root.AddXmlElement (SectionTypeName);
+			// modulesElement.AddXmlComment (" <module name=\"adminuser1\" secret=\"supersecrettoken\" permission_level=\"0\" /> ");
+
+			foreach ((string _, WebUser module) in users) {
+				module.ToXml (usersElement);
+			}
+		}
+
+#endregion
+
+
+#region Runtime interaction
+
+
+		public void AddUser (string _name, string _password, PlatformUserIdentifierAbs _userIdentifier, PlatformUserIdentifierAbs _crossPlatformIdentifier) {
+			WebUser p = new WebUser (_name, _password, _userIdentifier, _crossPlatformIdentifier);
+			
+			// TODO: Check if another name exists with the same (crossplatform)identifier, remove that
+			users [_name] = p;
+			
+			Parent.Save ();
+		}
+
+		public bool RemoveUser (string _name) {
+			bool removed = users.Remove (_name);
+			if (removed) {
+				Parent.Save ();
+			}
+
+			return removed;
+		}
+
+		public Dictionary<string, WebUser> GetUsers () {
+			return users;
+		}
+
+#endregion
+
+		public readonly struct WebUser {
+			public readonly string Name;
+			public readonly byte[] PasswordHash;
+			public readonly PlatformUserIdentifierAbs PlatformUser;
+			public readonly PlatformUserIdentifierAbs CrossPlatformUser;
+
+			public WebUser (string _name, byte[] _passwordHash, PlatformUserIdentifierAbs _platformUser, PlatformUserIdentifierAbs _crossPlatformUser) {
+				Name = _name;
+				PasswordHash = _passwordHash;
+				PlatformUser = _platformUser;
+				CrossPlatformUser = _crossPlatformUser;
+			}
+			
+			public WebUser (string _name, string _password, PlatformUserIdentifierAbs _platformUser, PlatformUserIdentifierAbs _crossPlatformUser) {
+				Name = _name;
+				PasswordHash = Hash (_password);
+				PlatformUser = _platformUser;
+				CrossPlatformUser = _crossPlatformUser;
+			}
+
+			public void ToXml (XmlElement _parent) {
+				XmlElement elem = _parent.AddXmlElement ("user");
+				elem.SetAttrib ("name", Name)
+					.SetAttrib ("pass", Convert.ToBase64String(PasswordHash));
+				PlatformUser.ToXml (elem);
+				CrossPlatformUser?.ToXml (elem, "cross");
+			}
+
+			public static bool TryParse (XmlElement _element, out WebUser _result) {
+				_result = default;
+
+				if (!_element.TryGetAttribute ("name", out string name)) {
+					Log.Warning ($"[Web] [Perms] Ignoring user-entry because of missing 'name' attribute: {_element.OuterXml}");
+					return false;
+				}
+
+				if (!_element.TryGetAttribute ("pass", out string passHashString)) {
+					Log.Warning ($"[Web] [Perms] Ignoring user-entry because of missing 'pass' attribute: {_element.OuterXml}");
+					return false;
+				}
+
+				PlatformUserIdentifierAbs userIdentifier = PlatformUserIdentifierAbs.FromXml (_element, false);
+				if (userIdentifier == null) {
+					Log.Warning ($"[Web] [Perms] Ignoring user-entry because of missing 'platform' or 'userid' attribute: {_element.OuterXml}");
+					return false;
+				}
+
+				PlatformUserIdentifierAbs crossIdentifier = PlatformUserIdentifierAbs.FromXml (_element, false, "cross");
+
+				byte[] passHash = Convert.FromBase64String (passHashString);
+
+				_result = new WebUser (name, passHash, userIdentifier, crossIdentifier);
+				return true;
+			}
+
+			public bool ValidatePassword (string _password) {
+				byte[] input = Hash (_password);
+				return Utils.ArrayEquals (input, PasswordHash);
+			}
+		}
+
+		private static byte[] Hash (string _input) {
+			System.Security.Cryptography.MD5Cng hasherMD5 = new System.Security.Cryptography.MD5Cng();
+
+			return hasherMD5.ComputeHash (Encoding.UTF8.GetBytes (_input));
+		}
+
+		public WebUser? GetUser (string _name, string _password) {
+			if (users.TryGetValue (_name, out WebUser user) && user.ValidatePassword (_password)) {
+				return user;
+			}
+
+			return null;
+		}
+
+
+#region Specials
+
+		
+#endregion
+	}
+}
Index: /binary-improvements2/WebServer/src/Permissions/RegisterModules.cs
===================================================================
--- /binary-improvements2/WebServer/src/Permissions/RegisterModules.cs	(revision 404)
+++ /binary-improvements2/WebServer/src/Permissions/RegisterModules.cs	(revision 404)
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+using HarmonyLib;
+using JetBrains.Annotations;
+
+namespace Webserver.Permissions {
+	[HarmonyPatch(typeof(AdminTools))]
+	[HarmonyPatch("registerModules")]
+	public static class RegisterModules {
+		[UsedImplicitly]
+		private static void Postfix (AdminTools __instance, Dictionary<string, AdminSectionAbs> ___modules) {
+			if (AdminApiTokens.Instance != null) {
+				return;
+			}
+			
+			AdminApiTokens apiTokens = new AdminApiTokens (__instance);
+			AdminWebModules webModules = new AdminWebModules (__instance);
+			AdminWebUsers webUsers = new AdminWebUsers (__instance);
+			
+			___modules.Add (apiTokens.SectionTypeName, apiTokens);
+			___modules.Add (webModules.SectionTypeName, webModules);
+			___modules.Add (webUsers.SectionTypeName, webUsers);
+		}
+	}
+}
Index: /binary-improvements2/WebServer/src/UrlHandlers/AbsHandler.cs
===================================================================
--- /binary-improvements2/WebServer/src/UrlHandlers/AbsHandler.cs	(revision 403)
+++ /binary-improvements2/WebServer/src/UrlHandlers/AbsHandler.cs	(revision 404)
@@ -1,2 +1,4 @@
+using Webserver.Permissions;
+
 namespace Webserver.UrlHandlers {
 	public abstract class AbsHandler {
@@ -10,5 +12,5 @@
 		protected AbsHandler (string _moduleName, int _defaultPermissionLevel = 0) {
 			moduleName = _moduleName;
-			WebPermissions.Instance.AddKnownModule (_moduleName, _defaultPermissionLevel);
+			AdminWebModules.Instance.AddKnownModule (_moduleName, _defaultPermissionLevel);
 		}
 
@@ -16,5 +18,5 @@
 
 		public virtual bool IsAuthorizedForHandler (WebConnection _user, int _permissionLevel) {
-			return moduleName == null || WebPermissions.Instance.ModuleAllowedWithLevel (moduleName, _permissionLevel);
+			return moduleName == null || AdminWebModules.Instance.ModuleAllowedWithLevel (moduleName, _permissionLevel);
 		}
 
Index: /binary-improvements2/WebServer/src/UrlHandlers/ApiHandler.cs
===================================================================
--- /binary-improvements2/WebServer/src/UrlHandlers/ApiHandler.cs	(revision 403)
+++ /binary-improvements2/WebServer/src/UrlHandlers/ApiHandler.cs	(revision 404)
@@ -3,4 +3,5 @@
 using System.Net;
 using System.Reflection;
+using Webserver.Permissions;
 using Webserver.WebAPI;
 
@@ -10,31 +11,17 @@
 
 		public ApiHandler () : base (null) {
-
 		}
+		
+		private static readonly Type[] apiWithParentCtorTypes = { typeof (Web) };
+		private static readonly object[] apiWithParentCtorArgs = new object[1];
+		private static readonly Type[] apiEmptyCtorTypes = { };
+		private static readonly object[] apiEmptyCtorArgs = { };
 
 		public override void SetBasePathAndParent (Web _parent, string _relativePath) {
 			base.SetBasePathAndParent (_parent, _relativePath);
 
-			Type[] apiWithParentCtorTypes = { typeof (Web) };
-			object[] apiWithParentCtorArgs = { _parent };
+			apiWithParentCtorArgs[0] = _parent;
 
-			Type[] apiEmptyCtorTypes = { };
-			object[] apiEmptyCtorArgs = { };
-			
-			
-			ReflectionHelpers.FindTypesImplementingBase (typeof (AbsWebAPI), _type => {
-				ConstructorInfo ctor = _type.GetConstructor (apiWithParentCtorTypes);
-				if (ctor != null) {
-					AbsWebAPI apiInstance = (AbsWebAPI) ctor.Invoke (apiWithParentCtorArgs);
-					addApi (apiInstance);
-					return;
-				}
-					
-				ctor = _type.GetConstructor (apiEmptyCtorTypes);
-				if (ctor != null) {
-					AbsWebAPI apiInstance = (AbsWebAPI) ctor.Invoke (apiEmptyCtorArgs);
-					addApi (apiInstance);
-				}
-			});
+			ReflectionHelpers.FindTypesImplementingBase (typeof (AbsWebAPI), apiFoundCallback);
 
 			// Permissions that don't map to a real API
@@ -43,7 +30,22 @@
 		}
 
+		private void apiFoundCallback (Type _type) {
+			ConstructorInfo ctor = _type.GetConstructor (apiWithParentCtorTypes);
+			if (ctor != null) {
+				AbsWebAPI apiInstance = (AbsWebAPI)ctor.Invoke (apiWithParentCtorArgs);
+				addApi (apiInstance);
+				return;
+			}
+
+			ctor = _type.GetConstructor (apiEmptyCtorTypes);
+			if (ctor != null) {
+				AbsWebAPI apiInstance = (AbsWebAPI)ctor.Invoke (apiEmptyCtorArgs);
+				addApi (apiInstance);
+			}
+		}
+
 		private void addApi (AbsWebAPI _api) {
 			apis.Add (_api.Name, _api);
-			WebPermissions.Instance.AddKnownModule ($"webapi.{_api.Name}", _api.DefaultPermissionLevel ());
+			AdminWebModules.Instance.AddKnownModule ($"webapi.{_api.Name}", _api.DefaultPermissionLevel ());
 		}
 
@@ -92,5 +94,5 @@
 
 		private bool IsAuthorizedForApi (string _apiName, int _permissionLevel) {
-			return WebPermissions.Instance.ModuleAllowedWithLevel ($"webapi.{_apiName}", _permissionLevel);
+			return AdminWebModules.Instance.ModuleAllowedWithLevel ($"webapi.{_apiName}", _permissionLevel);
 		}
 	}
Index: /binary-improvements2/WebServer/src/UrlHandlers/SessionHandler.cs
===================================================================
--- /binary-improvements2/WebServer/src/UrlHandlers/SessionHandler.cs	(revision 403)
+++ /binary-improvements2/WebServer/src/UrlHandlers/SessionHandler.cs	(revision 404)
@@ -5,4 +5,5 @@
 using Platform.Steam;
 using Utf8Json;
+using Webserver.Permissions;
 
 namespace Webserver.UrlHandlers {
@@ -10,7 +11,9 @@
 		private const string pageBasePath = "/app";
 		private const string pageErrorPath = "/app/error/";
-		
+
 		private const string steamOpenIdVerifyUrl = "verifysteamopenid";
 		private const string steamLoginUrl = "loginsteam";
+		private const string steamLoginFailedPage = "SteamLoginFailed";
+
 		private const string userPassLoginUrl = "login";
 
@@ -29,6 +32,8 @@
 			string subpath = _context.RequestPath.Remove (0, urlBasePath.Length);
 
+			string remoteEndpointString = _context.Request.RemoteEndPoint!.ToString ();
+
 			if (subpath.StartsWith (steamOpenIdVerifyUrl)) {
-				HandleSteamVerification (_context);
+				HandleSteamVerification (_context, remoteEndpointString);
 				return;
 			}
@@ -43,7 +48,7 @@
 				return;
 			}
-			
+
 			if (subpath.StartsWith (userPassLoginUrl)) {
-				HandleUserPassLogin (_context);
+				HandleUserPassLogin (_context, remoteEndpointString);
 				return;
 			}
@@ -52,5 +57,5 @@
 		}
 
-		private void HandleUserPassLogin (RequestContext _context) {
+		private void HandleUserPassLogin (RequestContext _context, string _remoteEndpointString) {
 			if (!_context.Request.HasEntityBody) {
 				_context.Response.Redirect (pageErrorPath + "NoLoginData");
@@ -83,38 +88,13 @@
 			}
 
-			// TODO: Apply login
+			AdminWebUsers.WebUser? webUser = AdminWebUsers.Instance.GetUser (username, password);
 
-			string remoteEndpointString = _context.Request.RemoteEndPoint!.ToString ();
-
-			if (username != "test" || password != "123") {
-				// TODO: failed login
-				Log.Out ($"[Web] User/pass login failed from {remoteEndpointString}");
+			if (!webUser.HasValue) {
+				Log.Out ($"[Web] User/pass login failed from {_remoteEndpointString}");
 				_context.Response.Redirect (pageErrorPath + "UserPassInvalid");
 				return;
 			}
-			
-			try {
-				// TODO: Match username/password to UserIdentifierAbs / serveradmins.xml
-				
-				WebConnection con = connectionHandler.LogIn (new UserIdentifierSteam (76561198066968172ul), _context.Request.RemoteEndPoint.Address);
-				int level = GameManager.Instance.adminTools.GetUserPermissionLevel (con.UserId);
-				Log.Out ($"[Web] User/pass login from {remoteEndpointString} with ID {con.UserId}, permission level {level}");
 
-				Cookie cookie = new Cookie ("sid", con.SessionID, "/") {
-					Expired = false,
-					Expires = DateTime.MinValue,
-					HttpOnly = true,
-					Secure = false
-				};
-				_context.Response.AppendCookie (cookie);
-				_context.Response.Redirect (pageBasePath);
-
-				return;
-			} catch (Exception e) {
-				Log.Error ("[Web] Error during user/pass login:");
-				Log.Exception (e);
-			}
-
-			_context.Response.Redirect (pageErrorPath + "UserPassLoginFailed");
+			HandleUserIdLogin (_context, _remoteEndpointString, "user/pass", "UserPassLoginFailed", webUser.Value.Name, webUser.Value.PlatformUser, webUser.Value.CrossPlatformUser);
 		}
 
@@ -140,34 +120,53 @@
 		}
 
-		private void HandleSteamVerification (RequestContext _context) {
-			string remoteEndpointString = _context.Request.RemoteEndPoint!.ToString ();
-
+		private void HandleSteamVerification (RequestContext _context, string _remoteEndpointString) {
+			ulong id;
 			try {
-				ulong id = OpenID.Validate (_context.Request);
-				if (id > 0) {
-					WebConnection con = connectionHandler.LogIn (new UserIdentifierSteam (id), _context.Request.RemoteEndPoint.Address);
-					int level = GameManager.Instance.adminTools.GetUserPermissionLevel (con.UserId);
-					Log.Out ($"[Web] Steam OpenID login from {remoteEndpointString} with ID {con.UserId}, permission level {level}");
-
-					Cookie cookie = new Cookie ("sid", con.SessionID, "/") {
-						Expired = false,
-						Expires = DateTime.MinValue,
-						HttpOnly = true,
-						Secure = false
-					};
-					_context.Response.AppendCookie (cookie);
-					_context.Response.Redirect (pageBasePath);
-
-					return;
-				}
+				id = OpenID.Validate (_context.Request);
 			} catch (Exception e) {
-				Log.Error ("[Web] Error validating Steam login:");
+				Log.Error ($"[Web] Error validating Steam login from {_remoteEndpointString}:");
 				Log.Exception (e);
+				_context.Response.Redirect (pageErrorPath + steamLoginFailedPage);
+				return;
 			}
 
-			Log.Out ($"[Web] Steam OpenID login failed from {remoteEndpointString}");
-			_context.Response.Redirect (pageErrorPath + "SteamLoginFailed");
+			if (id <= 0) {
+				Log.Out ($"[Web] Steam OpenID login failed (invalid ID) from {_remoteEndpointString}");
+				_context.Response.Redirect (pageErrorPath + steamLoginFailedPage);
+				return;
+			}
+
+			UserIdentifierSteam userId = new UserIdentifierSteam (id);
+			HandleUserIdLogin (_context, _remoteEndpointString, "Steam OpenID", steamLoginFailedPage, userId.ToString (), userId);
 		}
 
+		private void HandleUserIdLogin (RequestContext _context, string _remoteEndpointString, string _loginName, string _errorPage, string _username,
+			PlatformUserIdentifierAbs _userId, PlatformUserIdentifierAbs _crossUserId = null) {
+			try {
+				WebConnection con = connectionHandler.LogIn (_context.Request.RemoteEndPoint!.Address, _username, _userId, _crossUserId);
+
+				int level1 = GameManager.Instance.adminTools.Users.GetUserPermissionLevel (_userId);
+				int level2 = int.MaxValue;
+				if (_crossUserId != null) {
+					level2 = GameManager.Instance.adminTools.Users.GetUserPermissionLevel (_crossUserId);
+				}
+
+				int higherLevel = Math.Min (level1, level2);
+
+				Log.Out ($"[Web] {_loginName} login from {_remoteEndpointString}, name {_username} with ID {_userId}, CID {(_crossUserId != null ? _crossUserId : "none")}, permission level {higherLevel}");
+				Cookie cookie = new Cookie ("sid", con.SessionID, "/") {
+					Expired = false,
+					Expires = DateTime.MinValue,
+					HttpOnly = true,
+					Secure = false
+				};
+				_context.Response.AppendCookie (cookie);
+				_context.Response.Redirect (pageBasePath);
+			} catch (Exception e) {
+				Log.Error ($"[Web] Error during {_loginName} login:");
+				Log.Exception (e);
+				_context.Response.Redirect (pageErrorPath + _errorPage);
+			}
+		}
 	}
 }
Index: /binary-improvements2/WebServer/src/UrlHandlers/SseHandler.cs
===================================================================
--- /binary-improvements2/WebServer/src/UrlHandlers/SseHandler.cs	(revision 403)
+++ /binary-improvements2/WebServer/src/UrlHandlers/SseHandler.cs	(revision 404)
@@ -4,4 +4,5 @@
 using System.Reflection;
 using System.Threading;
+using Webserver.Permissions;
 using Webserver.SSE;
 
@@ -17,21 +18,21 @@
 		private bool shutdown;
 
+		private static readonly Type[] ctorTypes = { typeof (SseHandler) };
+		private static readonly object[] ctorParams = new object[1];
+
 		public SseHandler (string _moduleName = null) : base (_moduleName) {
-			Type[] ctorTypes = { typeof (SseHandler) };
-			object[] ctorParams = { this };
+			ctorParams[0] = this;
 
-			foreach (Type t in Assembly.GetExecutingAssembly ().GetTypes ()) {
-				if (t.IsAbstract || !t.IsSubclassOf (typeof (AbsEvent))) {
-					continue;
-				}
+			ReflectionHelpers.FindTypesImplementingBase (typeof (AbsEvent), apiFoundCallback);
+		}
 
-				ConstructorInfo ctor = t.GetConstructor (ctorTypes);
-				if (ctor == null) {
-					continue;
-				}
+		private void apiFoundCallback (Type _type) {
+			ConstructorInfo ctor = _type.GetConstructor (ctorTypes);
+			if (ctor == null) {
+				return;
+			}
 
-				AbsEvent apiInstance = (AbsEvent)ctor.Invoke (ctorParams);
-				AddEvent (apiInstance.Name, apiInstance);
-			}
+			AbsEvent apiInstance = (AbsEvent)ctor.Invoke (ctorParams);
+			AddEvent (apiInstance.Name, apiInstance);
 		}
 
@@ -52,5 +53,5 @@
 		public void AddEvent (string _eventName, AbsEvent _eventInstance) {
 			events.Add (_eventName, _eventInstance);
-			WebPermissions.Instance.AddKnownModule ($"webevent.{_eventName}", _eventInstance.DefaultPermissionLevel ());
+			AdminWebModules.Instance.AddKnownModule ($"webevent.{_eventName}", _eventInstance.DefaultPermissionLevel ());
 		}
 
@@ -89,5 +90,5 @@
 
 		private bool IsAuthorizedForEvent (string _eventName, int _permissionLevel) {
-			return WebPermissions.Instance.ModuleAllowedWithLevel ($"webevent.{_eventName}", _permissionLevel);
+			return AdminWebModules.Instance.ModuleAllowedWithLevel ($"webevent.{_eventName}", _permissionLevel);
 		}
 
Index: /binary-improvements2/WebServer/src/UrlHandlers/UserStatusHandler.cs
===================================================================
--- /binary-improvements2/WebServer/src/UrlHandlers/UserStatusHandler.cs	(revision 403)
+++ /binary-improvements2/WebServer/src/UrlHandlers/UserStatusHandler.cs	(revision 404)
@@ -1,3 +1,5 @@
+using System.Collections.Generic;
 using Utf8Json;
+using Webserver.Permissions;
 
 namespace Webserver.UrlHandlers {
@@ -8,4 +10,5 @@
 		private static readonly byte[] jsonLoggedInKey = JsonWriter.GetEncodedPropertyNameWithBeginObject ("loggedIn");
 		private static readonly byte[] jsonUsernameKey = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator ("username");
+		private static readonly byte[] jsonPermissionLevelKey = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator ("permissionLevel");
 		private static readonly byte[] jsonPermissionsKey = JsonWriter.GetEncodedPropertyNameWithPrefixValueSeparator ("permissions");
 
@@ -20,23 +23,26 @@
 			
 			writer.WriteRaw (jsonUsernameKey);
-			writer.WriteString (_context.Connection != null ? _context.Connection.UserId.ToString () : string.Empty);
+			writer.WriteString (_context.Connection != null ? _context.Connection.Username : string.Empty);
+			
+			writer.WriteRaw (jsonPermissionLevelKey);
+			writer.WriteInt32 (_context.PermissionLevel);
 			
 			writer.WriteRaw (jsonPermissionsKey);
 			writer.WriteBeginArray ();
 
-			bool first = true;
-			foreach (WebPermissions.WebModulePermission perm in WebPermissions.Instance.GetModules ()) {
-				if (!first) {
+			List<AdminWebModules.WebModule> list = AdminWebModules.Instance.GetModules ();
+			for (int i = 0; i < list.Count; i++) {
+				AdminWebModules.WebModule perm = list [i];
+				
+				if (i > 0) {
 					writer.WriteValueSeparator ();
 				}
 
-				first = false;
-				
 				writer.WriteRaw (jsonModuleKey);
-				writer.WriteString (perm.module);
-				
+				writer.WriteString (perm.Name);
+
 				writer.WriteRaw (jsonAllowedKey);
-				writer.WriteBoolean (perm.permissionLevel >= _context.PermissionLevel);
-				
+				writer.WriteBoolean (perm.PermissionLevel >= _context.PermissionLevel);
+
 				writer.WriteEndObject ();
 			}
Index: /binary-improvements2/WebServer/src/Web.cs
===================================================================
--- /binary-improvements2/WebServer/src/Web.cs	(revision 403)
+++ /binary-improvements2/WebServer/src/Web.cs	(revision 404)
@@ -6,4 +6,5 @@
 using UnityEngine;
 using Webserver.FileCache;
+using Webserver.Permissions;
 using Webserver.UrlHandlers;
 using Cookie = System.Net.Cookie;
@@ -25,4 +26,6 @@
 		private readonly Version httpProtocolVersion = new Version(1, 1);
 
+		private readonly AsyncCallback handleRequestDelegate;
+
 		public Web (string _modInstancePath) {
 			try {
@@ -81,5 +84,6 @@
 				// listener.Prefixes.Add ($"http://[::1]:{webPort}/");
 				listener.Start ();
-				listener.BeginGetContext (HandleRequest, listener);
+				handleRequestDelegate = HandleRequest;
+				listener.BeginGetContext (handleRequestDelegate, listener);
 
 				SdtdConsole.Instance.RegisterServer (this);
@@ -250,5 +254,5 @@
 #if ENABLE_PROFILER
 			} finally {
-				listenerInstance.BeginGetContext (HandleRequest, listenerInstance);
+				listenerInstance.BeginGetContext (handleRequestDelegate, listenerInstance);
 				UnityEngine.Profiling.Profiler.EndThreadProfiling ();
 			}
@@ -297,16 +301,22 @@
 				_con = connectionHandler.IsLoggedIn (sessionId, reqRemoteEndPoint.Address);
 				if (_con != null) {
-					return GameManager.Instance.adminTools.GetUserPermissionLevel (_con.UserId);
-				}
-			}
-
-			if (_req.QueryString ["adminuser"] == null || _req.QueryString ["admintoken"] == null) {
+					int level1 = GameManager.Instance.adminTools.Users.GetUserPermissionLevel (_con.UserId);
+					int level2 = int.MaxValue;
+					if (_con.CrossplatformUserId != null) {
+						level2 = GameManager.Instance.adminTools.Users.GetUserPermissionLevel (_con.CrossplatformUserId);
+					}
+
+					return Math.Min (level1, level2);
+				}
+			}
+
+			if (!_req.Headers.TryGetValue ("X-SDTD-API-TOKENNAME", out string apiTokenName) ||
+			    !_req.Headers.TryGetValue ("X-SDTD-API-SECRET", out string apiTokenSecret)) {
 				return guestPermissionLevel;
 			}
 
-			WebPermissions.AdminToken admin = WebPermissions.Instance.GetWebAdmin (_req.QueryString ["adminuser"],
-				_req.QueryString ["admintoken"]);
-			if (admin != null) {
-				return admin.permissionLevel;
+			int adminLevel = AdminApiTokens.Instance.GetPermissionLevel (apiTokenName, apiTokenSecret);
+			if (adminLevel < int.MaxValue) {
+				return adminLevel;
 			}
 
Index: /binary-improvements2/WebServer/src/WebAPI/APIs/Command.cs
===================================================================
--- /binary-improvements2/WebServer/src/WebAPI/APIs/Command.cs	(revision 403)
+++ /binary-improvements2/WebServer/src/WebAPI/APIs/Command.cs	(revision 404)
@@ -25,11 +25,11 @@
 
 			if (string.IsNullOrEmpty (id)) {
-				bool first = true;
-				foreach (IConsoleCommand cc in SdtdConsole.Instance.GetCommands ()) {
-					if (!first) {
+				IList<IConsoleCommand> ccs = SdtdConsole.Instance.GetCommands ();
+				for (int i = 0; i < ccs.Count; i++) {
+					IConsoleCommand cc = ccs [i];
+					
+					if (i > 0) {
 						writer.WriteValueSeparator ();
 					}
-
-					first = false;
 
 					writeCommandJson (ref writer, cc, permissionLevel);
@@ -81,5 +81,5 @@
 			_writer.WriteString (_command.GetHelp ());
 				
-			int commandPermissionLevel = GameManager.Instance.adminTools.GetCommandPermissionLevel (_command.GetCommands ());
+			int commandPermissionLevel = GameManager.Instance.adminTools.Commands.GetCommandPermissionLevel (_command.GetCommands ());
 			_writer.WriteRaw (jsonAllowedKey);
 			_writer.WriteBoolean (_userPermissionLevel <= commandPermissionLevel);
@@ -117,5 +117,5 @@
 			}
 
-			int commandPermissionLevel = GameManager.Instance.adminTools.GetCommandPermissionLevel (command.GetCommands ());
+			int commandPermissionLevel = GameManager.Instance.adminTools.Commands.GetCommandPermissionLevel (command.GetCommands ());
 
 			if (_context.PermissionLevel > commandPermissionLevel) {
Index: /binary-improvements2/WebServer/src/WebAPI/APIs/ServerInfo.cs
===================================================================
--- /binary-improvements2/WebServer/src/WebAPI/APIs/ServerInfo.cs	(revision 403)
+++ /binary-improvements2/WebServer/src/WebAPI/APIs/ServerInfo.cs	(revision 404)
@@ -1,2 +1,3 @@
+using System.Collections.Generic;
 using JetBrains.Annotations;
 using Utf8Json;
@@ -22,71 +23,64 @@
 			GameServerInfo gsi = ConnectionManager.Instance.LocalServerInfo;
 
-			bool first = true;
-			
-			
+			IList<GameInfoString> list = EnumUtils.Values<GameInfoString> ();
+			for (int i = 0; i < list.Count; i++) {
+				GameInfoString stringGamePref = list [i];
 
-			foreach (GameInfoString stringGamePref in EnumUtils.Values<GameInfoString> ()) {
-				string value = gsi.GetValue (stringGamePref);
-
-				if (!first) {
+				if (i > 0) {
 					writer.WriteValueSeparator ();
 				}
 
-				first = false;
-				
 				writer.WriteString (stringGamePref.ToStringCached ());
 				writer.WriteNameSeparator ();
-				
+
 				writer.WriteRaw (keyType);
 				writer.WriteString ("string");
-				
+
 				writer.WriteRaw (keyValue);
-				writer.WriteString (value);
-				
+				writer.WriteString (gsi.GetValue (stringGamePref));
+
 				writer.WriteEndObject ();
 			}
 
-			foreach (GameInfoInt intGamePref in EnumUtils.Values<GameInfoInt> ()) {
-				int value = gsi.GetValue (intGamePref);
+			IList<GameInfoInt> ints = EnumUtils.Values<GameInfoInt> ();
+			for (int i = 0; i < ints.Count; i++) {
+				GameInfoInt intGamePref = ints [i];
 
-				if (!first) {
+				if (i > 0) {
 					writer.WriteValueSeparator ();
 				}
 
-				first = false;
-				
 				writer.WriteString (intGamePref.ToStringCached ());
 				writer.WriteNameSeparator ();
-				
+
 				writer.WriteRaw (keyType);
 				writer.WriteString ("int");
-				
+
 				writer.WriteRaw (keyValue);
-				writer.WriteInt32 (value);
-				
+				writer.WriteInt32 (gsi.GetValue (intGamePref));
+
 				writer.WriteEndObject ();
 			}
 
-			foreach (GameInfoBool boolGamePref in EnumUtils.Values<GameInfoBool> ()) {
-				bool value = gsi.GetValue (boolGamePref);
+			IList<GameInfoBool> prefs = EnumUtils.Values<GameInfoBool> ();
+			for (int i = 0; i < prefs.Count; i++) {
+				GameInfoBool boolGamePref = prefs [i];
 
-				if (!first) {
+				if (i > 0) {
 					writer.WriteValueSeparator ();
 				}
 
-				first = false;
-				
 				writer.WriteString (boolGamePref.ToStringCached ());
 				writer.WriteNameSeparator ();
-				
+
 				writer.WriteRaw (keyType);
 				writer.WriteString ("bool");
-				
+
 				writer.WriteRaw (keyValue);
-				writer.WriteBoolean (value);
-				
+				writer.WriteBoolean (gsi.GetValue (boolGamePref));
+
 				writer.WriteEndObject ();
 			}
-			
+
 			writer.WriteEndObject ();
 			
Index: /binary-improvements2/WebServer/src/WebAPI/AbsRestApi.cs
===================================================================
--- /binary-improvements2/WebServer/src/WebAPI/AbsRestApi.cs	(revision 403)
+++ /binary-improvements2/WebServer/src/WebAPI/AbsRestApi.cs	(revision 404)
@@ -95,10 +95,4 @@
 		}
 
-		protected void SendErrorResult (RequestContext _context, HttpStatusCode _statusCode, byte[] _jsonInputData = null, string _errorCode = null, Exception _exception = null) {
-			PrepareEnvelopedResult (out JsonWriter writer);
-			writer.WriteRaw (JsonEmptyData);
-			SendEnvelopedResult (_context, ref writer, _statusCode, _jsonInputData, _errorCode, _exception);
-		}
-
 		static AbsRestApi () {
 			JsonWriter writer = new JsonWriter ();
@@ -108,11 +102,30 @@
 		}
 
+		protected virtual void HandleRestGet (RequestContext _context) {
+			SendErrorResult (_context, HttpStatusCode.MethodNotAllowed, null, "Unsupported");
+		}
+
+		protected virtual void HandleRestPost (RequestContext _context, IDictionary<string, object> _jsonInput, byte[] _jsonInputData) {
+			SendErrorResult (_context, HttpStatusCode.MethodNotAllowed, _jsonInputData, "Unsupported");
+		}
+
+		protected virtual void HandleRestPut (RequestContext _context, IDictionary<string, object> _jsonInput, byte[] _jsonInputData) {
+			SendErrorResult (_context, HttpStatusCode.MethodNotAllowed, _jsonInputData, "Unsupported");
+		}
+
+		protected virtual void HandleRestDelete (RequestContext _context) {
+			SendErrorResult (_context, HttpStatusCode.MethodNotAllowed, null, "Unsupported");
+		}
+
+
+#region Helpers
+
 		protected static readonly byte[] JsonEmptyData;
 		
-		protected void PrepareEnvelopedResult (out JsonWriter _writer) {
+		protected static void PrepareEnvelopedResult (out JsonWriter _writer) {
 			WebUtils.PrepareEnvelopedResult (out _writer);
 		}
 
-		protected void SendEnvelopedResult (RequestContext _context, ref JsonWriter _writer, HttpStatusCode _statusCode = HttpStatusCode.OK,
+		protected static void SendEnvelopedResult (RequestContext _context, ref JsonWriter _writer, HttpStatusCode _statusCode = HttpStatusCode.OK,
 			byte[] _jsonInputData = null, string _errorCode = null, Exception _exception = null) {
 			
@@ -120,5 +133,11 @@
 		}
 
-		protected bool TryGetJsonField (IDictionary<string, object> _jsonObject, string _fieldName, out int _value) {
+		protected static void SendErrorResult (RequestContext _context, HttpStatusCode _statusCode, byte[] _jsonInputData = null, string _errorCode = null, Exception _exception = null) {
+			PrepareEnvelopedResult (out JsonWriter writer);
+			writer.WriteRaw (JsonEmptyData);
+			SendEnvelopedResult (_context, ref writer, _statusCode, _jsonInputData, _errorCode, _exception);
+		}
+
+		protected static bool TryGetJsonField (IDictionary<string, object> _jsonObject, string _fieldName, out int _value) {
 			_value = default;
 			
@@ -139,5 +158,5 @@
 		}
 
-		protected bool TryGetJsonField (IDictionary<string, object> _jsonObject, string _fieldName, out double _value) {
+		protected static bool TryGetJsonField (IDictionary<string, object> _jsonObject, string _fieldName, out double _value) {
 			_value = default;
 			
@@ -158,5 +177,5 @@
 		}
 
-		protected bool TryGetJsonField (IDictionary<string, object> _jsonObject, string _fieldName, out string _value) {
+		protected static bool TryGetJsonField (IDictionary<string, object> _jsonObject, string _fieldName, out string _value) {
 			_value = default;
 			
@@ -176,20 +195,7 @@
 			}
 		}
-
-		protected virtual void HandleRestGet (RequestContext _context) {
-			SendErrorResult (_context, HttpStatusCode.MethodNotAllowed, null, "Unsupported");
-		}
-
-		protected virtual void HandleRestPost (RequestContext _context, IDictionary<string, object> _jsonInput, byte[] _jsonInputData) {
-			SendErrorResult (_context, HttpStatusCode.MethodNotAllowed, _jsonInputData, "Unsupported");
-		}
-
-		protected virtual void HandleRestPut (RequestContext _context, IDictionary<string, object> _jsonInput, byte[] _jsonInputData) {
-			SendErrorResult (_context, HttpStatusCode.MethodNotAllowed, _jsonInputData, "Unsupported");
-		}
-
-		protected virtual void HandleRestDelete (RequestContext _context) {
-			SendErrorResult (_context, HttpStatusCode.MethodNotAllowed, null, "Unsupported");
-		}
+		
+
+#endregion
 	}
 }
Index: /binary-improvements2/WebServer/src/WebAPI/Null.cs
===================================================================
--- /binary-improvements2/WebServer/src/WebAPI/Null.cs	(revision 403)
+++ /binary-improvements2/WebServer/src/WebAPI/Null.cs	(revision 404)
@@ -1,3 +1,4 @@
-﻿using System.Text;
+﻿using System;
+using System.Text;
 
 namespace Webserver.WebAPI {
@@ -10,5 +11,5 @@
 			_context.Response.ContentType = "text/plain";
 			_context.Response.ContentEncoding = Encoding.ASCII;
-			_context.Response.OutputStream.Write (new byte[] { }, 0, 0);
+			_context.Response.OutputStream.Write (Array.Empty<byte> (), 0, 0);
 		}
 	}
Index: /binary-improvements2/WebServer/src/WebConnection.cs
===================================================================
--- /binary-improvements2/WebServer/src/WebConnection.cs	(revision 403)
+++ /binary-improvements2/WebServer/src/WebConnection.cs	(revision 404)
@@ -3,4 +3,5 @@
 using System.Net;
 using UnityEngine;
+using Webserver.Permissions;
 
 namespace Webserver {
@@ -15,12 +16,16 @@
 		public IPAddress Endpoint { get; }
 
+		public string Username { get; }
 		public PlatformUserIdentifierAbs UserId { get; }
+		public PlatformUserIdentifierAbs CrossplatformUserId { get; }
 
 		public TimeSpan Age => DateTime.Now - lastAction;
 
-		public WebConnection (string _sessionId, IPAddress _endpoint, PlatformUserIdentifierAbs _userId) {
+		public WebConnection (string _sessionId, IPAddress _endpoint, string _username, PlatformUserIdentifierAbs _userId, PlatformUserIdentifierAbs _crossUserId = null) {
 			SessionID = _sessionId;
 			Endpoint = _endpoint;
+			Username = _username;
 			UserId = _userId;
+			CrossplatformUserId = _crossUserId;
 			login = DateTime.Now;
 			lastAction = login;
@@ -29,9 +34,9 @@
 
 		public static bool CanViewAllPlayers (int _permissionLevel) {
-			return WebPermissions.Instance.ModuleAllowedWithLevel ("webapi.viewallplayers", _permissionLevel);
+			return AdminWebModules.Instance.ModuleAllowedWithLevel ("webapi.viewallplayers", _permissionLevel);
 		}
 
 		public static bool CanViewAllClaims (int _permissionLevel) {
-			return WebPermissions.Instance.ModuleAllowedWithLevel ("webapi.viewallclaims", _permissionLevel);
+			return AdminWebModules.Instance.ModuleAllowedWithLevel ("webapi.viewallclaims", _permissionLevel);
 		}
 
Index: nary-improvements2/WebServer/src/WebPermissions.cs
===================================================================
--- /binary-improvements2/WebServer/src/WebPermissions.cs	(revision 403)
+++ 	(revision )
@@ -1,362 +1,0 @@
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.IO;
-using System.Xml;
-
-namespace Webserver {
-	public class WebPermissions {
-		private const string permissionsFileName = "webpermissions.xml";
-		private static WebPermissions instance;
-
-		private readonly FileSystemWatcher fileWatcher;
-
-		private readonly WebModulePermission defaultModulePermission = new WebModulePermission ("", 0);
-
-		/// <summary>
-		///  Registered user/pass admin tokens
-		/// </summary>
-		private readonly Dictionary<string, AdminToken> adminTokens = new CaseInsensitiveStringDictionary<AdminToken> ();
-
-		/// <summary>
-		/// Contains all registered modules and their default permission
-		/// </summary>
-		private readonly Dictionary<string, WebModulePermission> knownModules = new CaseInsensitiveStringDictionary<WebModulePermission> ();
-
-		/// <summary>
-		/// Manually defined module permissions
-		/// </summary>
-		private readonly Dictionary<string, WebModulePermission> modulePermissions =
-			new CaseInsensitiveStringDictionary<WebModulePermission> ();
-
-		/// <summary>
-		/// Public list of all modules, both those with custom permissions as well as those that do not with their default permission
-		/// </summary>
-		private readonly List<WebModulePermission> allModulesList = new List<WebModulePermission> ();
-
-		private readonly ReadOnlyCollection<WebModulePermission> allModulesListRo;
-
-		private static string SettingsFilePath => GamePrefs.GetString (EnumUtils.Parse<EnumGamePrefs> (nameof (EnumGamePrefs.SaveGameFolder)));
-		private static string SettingsFileName => permissionsFileName;
-		private static string SettingsFullPath => $"{SettingsFilePath}/{SettingsFileName}";
-
-		private WebPermissions () {
-			allModulesListRo = new ReadOnlyCollection<WebModulePermission> (allModulesList);
-
-			Directory.CreateDirectory (SettingsFilePath);
-
-			Load ();
-
-			fileWatcher = new FileSystemWatcher (SettingsFilePath, SettingsFileName);
-			fileWatcher.Changed += OnFileChanged;
-			fileWatcher.Created += OnFileChanged;
-			fileWatcher.Deleted += OnFileChanged;
-			fileWatcher.EnableRaisingEvents = true;
-		}
-
-		public static WebPermissions Instance {
-			get {
-				lock (typeof (WebPermissions)) {
-					return instance ??= new WebPermissions ();
-				}
-			}
-		}
-
-
-#region Admin Tokens
-
-		public void AddAdmin (string _name, string _token, int _permissionLevel, bool _save = true) {
-			AdminToken c = new AdminToken (_name, _token, _permissionLevel);
-			lock (this) {
-				adminTokens [_name] = c;
-				if (_save) {
-					Save ();
-				}
-			}
-		}
-
-		public void RemoveAdmin (string _name, bool _save = true) {
-			lock (this) {
-				adminTokens.Remove (_name);
-				if (_save) {
-					Save ();
-				}
-			}
-		}
-
-		public bool IsAdmin (string _name) {
-			return adminTokens.ContainsKey (_name);
-		}
-
-		public AdminToken[] GetAdmins () {
-			AdminToken[] result = new AdminToken[adminTokens.Count];
-			adminTokens.CopyValuesTo (result);
-			return result;
-		}
-
-		public AdminToken GetWebAdmin (string _name, string _token) {
-			if (IsAdmin (_name) && adminTokens [_name].token == _token) {
-				return adminTokens [_name];
-			}
-
-			return null;
-		}
-
-#endregion
-
-
-#region Modules
-
-		public void AddModulePermission (string _module, int _permissionLevel, bool _save = true) {
-			WebModulePermission p = new WebModulePermission (_module, _permissionLevel);
-			lock (this) {
-				allModulesList.Clear ();
-				modulePermissions [_module] = p;
-				if (_save) {
-					Save ();
-				}
-			}
-		}
-
-		public void AddKnownModule (string _module, int _defaultPermission) {
-			if (string.IsNullOrEmpty (_module)) {
-				return;
-			}
-
-			WebModulePermission p = new WebModulePermission (_module, _defaultPermission);
-
-			lock (this) {
-				allModulesList.Clear ();
-				knownModules [_module] = p;
-			}
-		}
-
-		public bool IsKnownModule (string _module) {
-			if (string.IsNullOrEmpty (_module)) {
-				return false;
-			}
-
-			lock (this) {
-				return knownModules.ContainsKey (_module);
-			}
-		}
-
-		public void RemoveModulePermission (string _module, bool _save = true) {
-			lock (this) {
-				allModulesList.Clear ();
-				modulePermissions.Remove (_module);
-				if (_save) {
-					Save ();
-				}
-			}
-		}
-
-		public IList<WebModulePermission> GetModules () {
-			if (allModulesList.Count != 0) {
-				return allModulesListRo;
-			}
-
-			foreach ((string moduleName, WebModulePermission moduleDefaultPerm) in knownModules) {
-				allModulesList.Add (modulePermissions.TryGetValue (moduleName, out WebModulePermission modulePermission)
-					? modulePermission
-					: moduleDefaultPerm);
-			}
-
-			return allModulesListRo;
-		}
-
-		public bool ModuleAllowedWithLevel (string _module, int _level) {
-			WebModulePermission permInfo = GetModulePermission (_module);
-			return permInfo.permissionLevel >= _level;
-		}
-
-		public WebModulePermission GetModulePermission (string _module) {
-			if (modulePermissions.TryGetValue (_module, out WebModulePermission result)) {
-				return result;
-			}
-
-			return knownModules.TryGetValue (_module, out result) ? result : defaultModulePermission;
-		}
-
-#endregion
-
-
-#region IO Tasks
-
-		private void OnFileChanged (object _source, FileSystemEventArgs _e) {
-			Log.Out ($"[Web] [Perms] Reloading {SettingsFileName}");
-			Load ();
-		}
-
-		public void Load () {
-			adminTokens.Clear ();
-			modulePermissions.Clear ();
-
-			if (!File.Exists (SettingsFullPath)) {
-				Log.Out ($"[Web] [Perms] Permissions file '{SettingsFileName}' not found, creating.");
-				Save ();
-				return;
-			}
-
-			Log.Out ($"[Web] [Perms] Loading permissions file at '{SettingsFullPath}'");
-
-			XmlDocument xmlDoc = new XmlDocument ();
-
-			try {
-				xmlDoc.Load (SettingsFullPath);
-			} catch (XmlException e) {
-				Log.Error ($"[Web] [Perms] Failed loading permissions file: {e.Message}");
-				return;
-			}
-
-			XmlNode adminToolsNode = xmlDoc.DocumentElement;
-
-			if (adminToolsNode == null) {
-				Log.Error ("[Web] [Perms] Failed loading permissions file: No DocumentElement found");
-				return;
-			}
-
-			foreach (XmlNode childNode in adminToolsNode.ChildNodes) {
-				switch (childNode.Name) {
-					case "admintokens":
-						ParseAdminTokens (childNode);
-						break;
-					case "permissions":
-						ParseModulePermissions (childNode);
-						break;
-				}
-			}
-
-			Log.Out ("[Web] [Perms] Loading permissions file done.");
-		}
-
-		private void ParseAdminTokens (XmlNode _baseNode) {
-			foreach (XmlNode subChild in _baseNode.ChildNodes) {
-				if (subChild.NodeType == XmlNodeType.Comment) {
-					continue;
-				}
-
-				if (subChild.NodeType != XmlNodeType.Element) {
-					Log.Warning ($"[Web] [Perms] Unexpected XML node found in 'admintokens' section: {subChild.OuterXml}");
-					continue;
-				}
-
-				XmlElement lineItem = (XmlElement)subChild;
-
-				if (!lineItem.HasAttribute ("name")) {
-					Log.Warning ($"[Web] [Perms] Ignoring admintoken-entry because of missing 'name' attribute: {subChild.OuterXml}");
-					continue;
-				}
-
-				if (!lineItem.HasAttribute ("token")) {
-					Log.Warning ($"[Web] [Perms] Ignoring admintoken-entry because of missing 'token' attribute: {subChild.OuterXml}");
-					continue;
-				}
-
-				if (!lineItem.HasAttribute ("permission_level")) {
-					Log.Warning ($"[Web] [Perms] Ignoring admintoken-entry because of missing 'permission_level' attribute: {subChild.OuterXml}");
-					continue;
-				}
-
-				string name = lineItem.GetAttribute ("name");
-				string token = lineItem.GetAttribute ("token");
-				if (!int.TryParse (lineItem.GetAttribute ("permission_level"), out int permissionLevel)) {
-					Log.Warning (
-						$"[Web] [Perms] Ignoring admintoken-entry because of invalid (non-numeric) value for 'permission_level' attribute: {subChild.OuterXml}");
-					continue;
-				}
-
-				AddAdmin (name, token, permissionLevel, false);
-			}
-		}
-
-		private void ParseModulePermissions (XmlNode _baseNode) {
-			foreach (XmlNode subChild in _baseNode.ChildNodes) {
-				if (subChild.NodeType == XmlNodeType.Comment) {
-					continue;
-				}
-
-				if (subChild.NodeType != XmlNodeType.Element) {
-					Log.Warning ($"[Web] [Perms] Unexpected XML node found in 'permissions' section: {subChild.OuterXml}");
-					continue;
-				}
-
-				XmlElement lineItem = (XmlElement)subChild;
-
-				if (!lineItem.HasAttribute ("module")) {
-					Log.Warning ($"[Web] [Perms] Ignoring permission-entry because of missing 'module' attribute: {subChild.OuterXml}");
-					continue;
-				}
-
-				if (!lineItem.HasAttribute ("permission_level")) {
-					Log.Warning ($"[Web] [Perms] Ignoring permission-entry because of missing 'permission_level' attribute: {subChild.OuterXml}");
-					continue;
-				}
-
-				if (!int.TryParse (lineItem.GetAttribute ("permission_level"), out int permissionLevel)) {
-					Log.Warning (
-						$"[Web] [Perms] Ignoring permission-entry because of invalid (non-numeric) value for 'permission_level' attribute: {subChild.OuterXml}");
-					continue;
-				}
-
-				AddModulePermission (lineItem.GetAttribute ("module"), permissionLevel, false);
-			}
-		}
-
-		public void Save () {
-			XmlDocument xml = new XmlDocument ();
-
-			xml.CreateXmlDeclaration ();
-
-			// xml.AddXmlComment (XmlHeader);
-
-			XmlElement root = xml.AddXmlElement ("webpermissions");
-
-			// AdminTokens
-			XmlElement adminTokensElem = root.AddXmlElement ("admintokens");
-			adminTokensElem.AddXmlComment (" <token name=\"adminuser1\" token=\"supersecrettoken\" permission_level=\"0\" /> ");
-			foreach ((string _, AdminToken adminToken) in adminTokens) {
-				adminTokensElem.AddXmlElement ("token")
-					.SetAttrib ("name", adminToken.name)
-					.SetAttrib ("token", adminToken.token)
-					.SetAttrib ("permission_level", adminToken.permissionLevel.ToString ());
-			}
-
-			XmlElement modulePermissionsElem = root.AddXmlElement ("permissions");
-			foreach ((string _, WebModulePermission webModulePermission) in modulePermissions) {
-				modulePermissionsElem.AddXmlElement ("permission")
-					.SetAttrib ("module", webModulePermission.module)
-					.SetAttrib ("permission_level", webModulePermission.permissionLevel.ToString ());
-			}
-			
-
-			fileWatcher.EnableRaisingEvents = false;
-			xml.Save (SettingsFullPath);
-			fileWatcher.EnableRaisingEvents = true;
-		}
-
-#endregion
-
-
-		public class AdminToken {
-			public readonly string name;
-			public readonly int permissionLevel;
-			public readonly string token;
-
-			public AdminToken (string _name, string _token, int _permissionLevel) {
-				name = _name;
-				token = _token;
-				permissionLevel = _permissionLevel;
-			}
-		}
-
-		public struct WebModulePermission {
-			public readonly string module;
-			public readonly int permissionLevel;
-
-			public WebModulePermission (string _module, int _permissionLevel) {
-				module = _module;
-				permissionLevel = _permissionLevel;
-			}
-		}
-	}
-}
Index: /binary-improvements2/WebServer/src/WebUtils.cs
===================================================================
--- /binary-improvements2/WebServer/src/WebUtils.cs	(revision 403)
+++ /binary-improvements2/WebServer/src/WebUtils.cs	(revision 404)
@@ -1,3 +1,4 @@
 using System;
+using System.Collections.Specialized;
 using System.Diagnostics.CodeAnalysis;
 using System.Net;
@@ -110,4 +111,9 @@
 			WriteJsonData (_context.Response, ref _writer, _statusCode);
 		}
+
+		public static bool TryGetValue (this NameValueCollection _nameValueCollection, string _name, out string _result) {
+			_result = _nameValueCollection [_name];
+			return _result != null;
+		}
 	}
 }
