diff --git a/SimpleHttpServer/HttpEndpoint.cs b/SimpleHttpServer/HttpEndpoint.cs index 229efa1..0993696 100644 --- a/SimpleHttpServer/HttpEndpoint.cs +++ b/SimpleHttpServer/HttpEndpoint.cs @@ -1,4 +1,6 @@ -namespace SimpleHttpServer; +using SimpleHttpServer.Internal; + +namespace SimpleHttpServer; [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] public class HttpEndpoint : Attribute where T : IAuthorizer diff --git a/SimpleHttpServer/HttpServer.cs b/SimpleHttpServer/HttpServer.cs index 79fd6f9..63fdb41 100644 --- a/SimpleHttpServer/HttpServer.cs +++ b/SimpleHttpServer/HttpServer.cs @@ -1,5 +1,6 @@ using System.Net; using System.Reflection; +using SimpleHttpServer.Internal; namespace SimpleHttpServer; @@ -34,6 +35,9 @@ public sealed class HttpServer { PrintErrorOrThrow(error, endpoint, throwOnInvalidEndpoint, "HttpEndpointAttribute is only valid on static methods!"); continue; } + if (!endpoint.IsPublic) { + PrintErrorOrThrow(error, endpoint, throwOnInvalidEndpoint, $"{GetFancyMethodName(endpoint)} needs to be public!"); + } var myParams = endpoint.GetParameters(); if (myParams.Length <= 0 || !myParams[0].GetType().IsAssignableFrom(typeof(HttpListenerContext))) { PrintErrorOrThrow(error, endpoint, throwOnInvalidEndpoint, $"{GetFancyMethodName(endpoint)} needs to have a HttpListenerContext as its first argument!"); diff --git a/SimpleHttpServer/DefaultAuthorizer.cs b/SimpleHttpServer/Internal/DefaultAuthorizer.cs similarity index 57% rename from SimpleHttpServer/DefaultAuthorizer.cs rename to SimpleHttpServer/Internal/DefaultAuthorizer.cs index 0ab5b63..62a27b6 100644 --- a/SimpleHttpServer/DefaultAuthorizer.cs +++ b/SimpleHttpServer/Internal/DefaultAuthorizer.cs @@ -1,8 +1,7 @@ using System.Net; -namespace SimpleHttpServer; +namespace SimpleHttpServer.Internal; -public sealed class DefaultAuthorizer : IAuthorizer -{ +public sealed class DefaultAuthorizer : IAuthorizer { public (bool auth, object? data) IsAuthenticated(HttpListenerContext contect) => (true, null); } diff --git a/SimpleHttpServer/HttpEndpointHandler.cs b/SimpleHttpServer/Internal/HttpEndpointHandler.cs similarity index 98% rename from SimpleHttpServer/HttpEndpointHandler.cs rename to SimpleHttpServer/Internal/HttpEndpointHandler.cs index f0a5b17..948eadc 100644 --- a/SimpleHttpServer/HttpEndpointHandler.cs +++ b/SimpleHttpServer/Internal/HttpEndpointHandler.cs @@ -8,7 +8,7 @@ using System.Reflection; using System.Text; using System.Threading.Tasks; -namespace SimpleHttpServer; +namespace SimpleHttpServer.Internal; internal class HttpEndpointHandler { private static readonly DefaultAuthorizer defaultAuth = new(); @@ -37,7 +37,9 @@ internal class HttpEndpointHandler { var invokeParams = new object?[_params.Count + 1]; var set = new BitArray(_params.Count); invokeParams[0] = ctx; + // read pparams + // read qparams var qst = ctx.Request.QueryString; foreach (var qelem in ctx.Request.QueryString.AllKeys) { diff --git a/SimpleHttpServer/Login/LoginProvider.cs b/SimpleHttpServer/Login/LoginProvider.cs new file mode 100644 index 0000000..3082202 --- /dev/null +++ b/SimpleHttpServer/Login/LoginProvider.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Konscious.Security.Cryptography; +using Newtonsoft.Json; + +namespace SimpleHttpServer.Login; + +internal struct SerialLoginData { + public string uname; + public string salt; + public string pwd; + public string additionalData; + + public LoginData toPlainData() { + return new LoginData { + username = uname, + salt = Convert.FromBase64String(salt), + password = Convert.FromBase64String(pwd) + }; + } +} + +internal struct LoginData { + public string username; + public byte[] salt; + public byte[] password; + public byte[] encryptedData; + + public SerialLoginData toSerial() { + return new SerialLoginData { + uname = username, + salt = Convert.ToBase64String(salt), + pwd = Convert.ToBase64String(password), + additionalData = Convert.ToBase64String(encryptedData) + }; + } +} + +internal struct LoginDataProviderConfig { + + public int SALT_SIZE = 32; + public int KEY_LENGTH = 256 / 8; + public int A2_ITERATIONS = 5; + public int A2_MEMORY_SIZE = 500_000; + public int A2_PARALLELISM = 8; + public int A2_HASH_LENGTH = 256 / 8; + public int A2_MAX_CONCURRENT = 4; + public int PBKDF2_ITERATIONS = 600_000; + + public LoginDataProviderConfig() { } +} + +public class LoginProvider { + + private static readonly Func JsonSerialize = t => Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(t)); + private static readonly Func JsonDeserialize = b => JsonConvert.DeserializeObject(Encoding.UTF8.GetString(b))!; + + private readonly LoginDataProviderConfig config; + private readonly ReaderWriterLockSlim ldLock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion); + private readonly string ldPath; + private readonly Dictionary loginData; + private readonly SemaphoreSlim argon2Limit; + + private Func DataSerializer = JsonSerialize; + private Func DataDeserializer = JsonDeserialize; + + public LoginProvider(string ldPath, string confPath) { + this.ldPath = ldPath; + loginData = LoadLoginData(ldPath); + config = LoadArgon2Config(confPath); + argon2Limit = new SemaphoreSlim(config.A2_MAX_CONCURRENT); + } + + private static Dictionary LoadLoginData(string path) { + Dictionary tempData; + if (!File.Exists(path)) { + File.WriteAllText(path, "{}", Encoding.UTF8); + tempData = new(); + } else { + tempData = JsonConvert.DeserializeObject>(File.ReadAllText(path))!; + if (tempData == null) { + throw new InvalidDataException($"could not read login data from file {path}"); + } + } + var ld = new Dictionary(); + foreach (var pair in tempData!) { + ld.Add(pair.Key, pair.Value.toPlainData()); + } + return ld; + } + + private static LoginDataProviderConfig LoadArgon2Config(string path) { + if (!File.Exists(path)) { + var conf = new LoginDataProviderConfig(); + File.WriteAllText(path, JsonConvert.SerializeObject(conf)); + return conf; + } + return JsonConvert.DeserializeObject(File.ReadAllText(path)); + } + + public void SetDataSerialization(Func serializer, Func deserializer) { + DataSerializer = serializer ?? JsonSerialize; + DataDeserializer = deserializer ?? JsonDeserialize; + } + + private void StoreLoginData() { + var serial = new Dictionary(); + ldLock.EnterWriteLock(); + try { + foreach (var pair in loginData!) { + serial.Add(pair.Key, pair.Value.toSerial()); + } + } finally { + ldLock.ExitWriteLock(); + } + File.WriteAllText(ldPath, JsonConvert.SerializeObject(serial)); + } + + public bool AddUser(string username, string password, T additional) { + ldLock.EnterWriteLock(); + try { + if (loginData.ContainsKey(username)) { + return false; + } + var salt = RandomNumberGenerator.GetBytes(config.SALT_SIZE); + var pwdHash = HashPwd(password, salt); + LoginData ld = new LoginData() { + username = username, + salt = salt, + password = pwdHash, + encryptedData = EncryptAdditionalData(password, salt, additional) + }; + loginData.Add(username, ld); + StoreLoginData(); + } finally { + ldLock.ExitWriteLock(); + } + return true; + } + + public bool RemoveUser(string username) { + ldLock.EnterWriteLock(); + try { + var removed = loginData.Remove(username); + if (removed) { + StoreLoginData(); + } + return removed; + } finally { + ldLock.ExitWriteLock(); + } + } + + public bool ModifyUser(string username, string newPassword, T newAdditional) { + ldLock.EnterWriteLock(); + try { + if (!loginData.ContainsKey(username)) { + return false; + } + loginData.Remove(username, out var data); + data.password = HashPwd(newPassword, data.salt); + data.encryptedData = EncryptAdditionalData(newPassword, data.salt, newAdditional); + loginData.Add(username, data); + StoreLoginData(); + } finally { + ldLock.ExitWriteLock(); + } + return true; + } + + public (bool, T) Authenticate(string username, string password) { + LoginData data; + ldLock.EnterReadLock(); + try { + if (!loginData.TryGetValue(username, out data)) { + return (false, default(T)!); + } + } finally { + ldLock.ExitReadLock(); + } + var hash = HashPwd(password, data.salt); + if (!hash.SequenceEqual(data.password)) { + return (false, default(T)!); + } + return (true, DecryptAdditionalData(password, data.salt, data.encryptedData)); + } + + private byte[] HashPwd(string pwd, byte[] salt) { + byte[] hash; + argon2Limit.Wait(); + try { + using (var argon2 = new Argon2id(Encoding.UTF8.GetBytes(pwd))) { + argon2.Iterations = config.A2_ITERATIONS; + argon2.MemorySize = config.A2_MEMORY_SIZE; + argon2.DegreeOfParallelism = config.A2_PARALLELISM; + argon2.Salt = salt; + hash = argon2.GetBytes(config.A2_HASH_LENGTH); + } + // force collection to reduce sustained memory usage if many hashes are done in close time proximity to each other + GC.Collect(); + } finally { + argon2Limit.Release(); + } + return hash; + } + + private byte[] EncryptAdditionalData(string pwd, byte[] salt, T data) { + var pbkdf2 = new Rfc2898DeriveBytes(Encoding.UTF8.GetBytes(pwd), salt, config.PBKDF2_ITERATIONS, HashAlgorithmName.SHA256); + var key = pbkdf2.GetBytes(config.KEY_LENGTH / 8); + + var plainBytes = DataSerializer(data); + using var aes = Aes.Create(); + aes.KeySize = config.KEY_LENGTH; + aes.Key = key; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + ICryptoTransform encryptor = aes.CreateEncryptor(aes.Key, aes.IV); + byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); + + var encryptedBytes = new byte[aes.IV.Length + cipherBytes.Length]; + Array.Copy(aes.IV, 0, encryptedBytes, 0, aes.IV.Length); + Array.Copy(cipherBytes, 0, encryptedBytes, aes.IV.Length, cipherBytes.Length); + + return encryptedBytes; + } + + private T DecryptAdditionalData(string pwd, byte[] salt, byte[] encryptedData) { + var pbkdf2 = new Rfc2898DeriveBytes(Encoding.UTF8.GetBytes(pwd), salt, config.PBKDF2_ITERATIONS, HashAlgorithmName.SHA256); + var key = pbkdf2.GetBytes(config.KEY_LENGTH / 8); + + using var aes = Aes.Create(); + aes.KeySize = config.KEY_LENGTH; + aes.Key = key; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + var iv = new byte[aes.BlockSize / 8]; + var cipherBytes = new byte[encryptedData.Length - iv.Length]; + + Array.Copy(encryptedData, 0, iv, 0, iv.Length); + Array.Copy(encryptedData, iv.Length, cipherBytes, 0, cipherBytes.Length); + + aes.IV = iv; + ICryptoTransform decryptor = aes.CreateDecryptor(aes.Key, aes.IV); + byte[] plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); + + return DataDeserializer(plainBytes); + } +} diff --git a/SimpleHttpServer/SimpleHttpServer.csproj b/SimpleHttpServer/SimpleHttpServer.csproj index 51eb283..503f789 100644 --- a/SimpleHttpServer/SimpleHttpServer.csproj +++ b/SimpleHttpServer/SimpleHttpServer.csproj @@ -7,6 +7,7 @@ + diff --git a/SimpleHttpServerTest/SimpleHttpServerTest.csproj b/SimpleHttpServerTest/SimpleHttpServerTest.csproj index 01bb70b..12c4da0 100644 --- a/SimpleHttpServerTest/SimpleHttpServerTest.csproj +++ b/SimpleHttpServerTest/SimpleHttpServerTest.csproj @@ -1,4 +1,4 @@ - + net7.0 @@ -15,4 +15,8 @@ + + + + diff --git a/SimpleHttpServerTest/SimpleServerTest.cs b/SimpleHttpServerTest/SimpleServerTest.cs new file mode 100644 index 0000000..2dc83f0 --- /dev/null +++ b/SimpleHttpServerTest/SimpleServerTest.cs @@ -0,0 +1,15 @@ +using SimpleHttpServer; +using SimpleHttpServerTest.SimpleTestServer; + +namespace SimpleHttpServerTest; + +[TestClass] +public class SimpleServerTest { + [TestMethod] + public void RunTestServer() { + var server = HttpServer.Create(8833, "localhost", typeof(SimpleEndpointDefinition)); + server.Start(); + Console.WriteLine("press any key to exit"); + Assert.IsTrue(server.Shutdown(10000), "server did not exit gracefully"); + } +} \ No newline at end of file diff --git a/SimpleHttpServerTest/SimpleTestServer/LdAuthorizer.cs b/SimpleHttpServerTest/SimpleTestServer/LdAuthorizer.cs new file mode 100644 index 0000000..3b7afe9 --- /dev/null +++ b/SimpleHttpServerTest/SimpleTestServer/LdAuthorizer.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using SimpleHttpServer.Login; + +namespace SimpleHttpServerTest.SimpleTestServer; +internal class LdAuthorizer { + private readonly LoginProvider lprov; + + internal LdAuthorizer(LoginProvider lprov) { + this.lprov = lprov; + } + + +} diff --git a/SimpleHttpServerTest/SimpleTestServer/SimpleEndpointDefinition.cs b/SimpleHttpServerTest/SimpleTestServer/SimpleEndpointDefinition.cs new file mode 100644 index 0000000..7f0c00f --- /dev/null +++ b/SimpleHttpServerTest/SimpleTestServer/SimpleEndpointDefinition.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace SimpleHttpServerTest.SimpleTestServer; +internal class SimpleEndpointDefinition { + +} diff --git a/SimpleHttpServerTest/UnitTest1.cs b/SimpleHttpServerTest/UnitTest1.cs deleted file mode 100644 index 818d87c..0000000 --- a/SimpleHttpServerTest/UnitTest1.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace SimpleHttpServerTest; - -[TestClass] -public class UnitTest1 { - [TestMethod] - public void TestMethod1() { - } -} \ No newline at end of file