From b6dfaa344540a74c41d4cf2d15e0d78ccd1a07e6 Mon Sep 17 00:00:00 2001 From: Gary Sharp Date: Fri, 27 Dec 2024 14:28:56 +1100 Subject: [PATCH] feature: online activation --- .../Configuration/ConfigurationCache.cs | 49 +- .../Configuration/SystemConfiguration.cs | 23 + .../Migrations/202412180604170_DBv25.cs | 4 + Disco.Models/Disco.Models.csproj | 2 + .../DiscoServices/Activation/CallbackModel.cs | 11 + .../Activation/ChallengeModel.cs | 15 + Disco.Services/Disco.Services.csproj | 15 +- .../Extensions/DateTimeExtensions.cs | 7 +- .../Interop/DiscoServices/Activation.key | Bin 0 -> 140 bytes .../DiscoServices/ActivationCleanupTask.cs | 42 ++ .../DiscoServices/ActivationService.cs | 434 ++++++++++++ .../OnlineServicesAuthentication.cs | 167 +++++ .../API/Controllers/ActivationController.cs | 54 ++ .../Areas/API/Models/Activation/BeginModel.cs | 12 + .../API/Models/Activation/CallbackModel.cs | 13 + .../Areas/API/Views/Activation/Begin.cshtml | 21 + .../API/Views/Activation/Begin.generated.cs | 140 ++++ .../Views/Activation/_ActivateCallback.cshtml | 17 + .../Activation/_ActivateCallback.generated.cs | 107 +++ Disco.Web/Areas/API/Views/Web.config | 70 ++ Disco.Web/Areas/API/Views/_ViewStart.cshtml | 4 + .../Areas/API/Views/_ViewStart.generated.cs | 60 ++ .../Controllers/SystemConfigController.cs | 24 +- .../Models/SystemConfig/ActivateModel.cs | 12 + .../Config/Models/SystemConfig/IndexModel.cs | 7 + .../Config/Views/SystemConfig/Activate.cshtml | 55 ++ .../Views/SystemConfig/Activate.generated.cs | 232 +++++++ .../Config/Views/SystemConfig/Index.cshtml | 154 +++-- .../Views/SystemConfig/Index.generated.cs | 629 ++++++++++-------- .../Areas/Config/Views/_ViewStart.cshtml | 2 +- .../Config/Views/_ViewStart.generated.cs | 2 + Disco.Web/Disco.Web.csproj | 44 ++ .../API.ActivationController.generated.cs | 189 ++++++ ...Config.SystemConfigController.generated.cs | 15 + Disco.Web/Extensions/T4MVC/T4MVC.cs | 1 + 35 files changed, 2287 insertions(+), 346 deletions(-) create mode 100644 Disco.Models/Services/Interop/DiscoServices/Activation/CallbackModel.cs create mode 100644 Disco.Models/Services/Interop/DiscoServices/Activation/ChallengeModel.cs create mode 100644 Disco.Services/Interop/DiscoServices/Activation.key create mode 100644 Disco.Services/Interop/DiscoServices/ActivationCleanupTask.cs create mode 100644 Disco.Services/Interop/DiscoServices/ActivationService.cs create mode 100644 Disco.Services/Interop/DiscoServices/OnlineServicesAuthentication.cs create mode 100644 Disco.Web/Areas/API/Controllers/ActivationController.cs create mode 100644 Disco.Web/Areas/API/Models/Activation/BeginModel.cs create mode 100644 Disco.Web/Areas/API/Models/Activation/CallbackModel.cs create mode 100644 Disco.Web/Areas/API/Views/Activation/Begin.cshtml create mode 100644 Disco.Web/Areas/API/Views/Activation/Begin.generated.cs create mode 100644 Disco.Web/Areas/API/Views/Activation/_ActivateCallback.cshtml create mode 100644 Disco.Web/Areas/API/Views/Activation/_ActivateCallback.generated.cs create mode 100644 Disco.Web/Areas/API/Views/Web.config create mode 100644 Disco.Web/Areas/API/Views/_ViewStart.cshtml create mode 100644 Disco.Web/Areas/API/Views/_ViewStart.generated.cs create mode 100644 Disco.Web/Areas/Config/Models/SystemConfig/ActivateModel.cs create mode 100644 Disco.Web/Areas/Config/Views/SystemConfig/Activate.cshtml create mode 100644 Disco.Web/Areas/Config/Views/SystemConfig/Activate.generated.cs create mode 100644 Disco.Web/Extensions/T4MVC/API.ActivationController.generated.cs diff --git a/Disco.Data/Configuration/ConfigurationCache.cs b/Disco.Data/Configuration/ConfigurationCache.cs index 80d2c19c..95d2f4f3 100644 --- a/Disco.Data/Configuration/ConfigurationCache.cs +++ b/Disco.Data/Configuration/ConfigurationCache.cs @@ -242,9 +242,34 @@ namespace Disco.Data.Configuration } else if (itemType.BaseType != null && itemType.BaseType == typeof(Enum)) { - // Enum + // enum itemValue = Enum.Parse(typeof(T), item.Item1.Value); } + else if (itemType.IsGenericType && + itemType.GetGenericTypeDefinition() == typeof(Nullable<>) && + IsConvertableFromString(Nullable.GetUnderlyingType(itemType))) + { + // nullable + itemValue = (T)Convert.ChangeType(item.Item1.Value, Nullable.GetUnderlyingType(itemType)); + } + else if (itemType == typeof(Guid)) + { + // guid + itemValue = new Guid(item.Item1.Value); + } + else if (itemType == typeof(Guid?)) + { + // guid + if (string.IsNullOrEmpty(item.Item1.Value)) + itemValue = null; + else + itemValue = new Guid(item.Item1.Value); + } + else if (itemType == typeof(byte[])) + { + // byte[] + itemValue = Convert.FromBase64String(item.Item1.Value); + } else { // JSON Deserialize @@ -269,7 +294,7 @@ namespace Disco.Data.Configuration } else if (valueType == typeof(object)) { - throw new ArgumentException(string.Format("Cannot serialize the configuration item [{0}].[{1}] which defines a type of [System.Object]", Scope, Key), "Value"); + throw new ArgumentException($"Cannot serialize the configuration item [{Scope}].[{Key}] which has the type [System.Object]", "Value"); } else if (IsConvertableFromString(valueType)) { @@ -278,9 +303,25 @@ namespace Disco.Data.Configuration } else if (valueType.BaseType != null && valueType.BaseType == typeof(Enum)) { - // Enum + // enum stringValue = Value.ToString(); } + else if (valueType.IsGenericType && + valueType.GetGenericTypeDefinition() == typeof(Nullable<>) && + IsConvertableFromString(Nullable.GetUnderlyingType(valueType))) + { + // nullable + stringValue = Value.ToString(); + } + else if (valueType == typeof(Guid) || valueType == typeof(Guid?)) + { + stringValue = Value.ToString(); + } + else if (Value is byte[] valueBytes) + { + // byte[] + stringValue = Convert.ToBase64String(valueBytes); + } else { // JSON Serialize @@ -290,7 +331,7 @@ namespace Disco.Data.Configuration CacheSetItem(Database, Scope, Key, stringValue, Value); } } - + #endregion #region Cache Helpers diff --git a/Disco.Data/Configuration/SystemConfiguration.cs b/Disco.Data/Configuration/SystemConfiguration.cs index 570f3506..30ad8409 100644 --- a/Disco.Data/Configuration/SystemConfiguration.cs +++ b/Disco.Data/Configuration/SystemConfiguration.cs @@ -319,6 +319,29 @@ namespace Disco.Data.Configuration #endregion #region UpdateCheck + public bool IsActivated => ActivationId.HasValue; + + public DateTime? ActivatedOn + { + get => Get((DateTime?)null); + set => Set(value); + } + public string ActivatedBy + { + get => Get((string)null); + set => Set(value); + } + public Guid? ActivationId + { + get => Get((Guid?)null); + set => Set(value); + } + public byte[] ActivationKey + { + get => Get((byte[])null); + set => Set(value); + } + public bool IsLicensed { get => LicenseKey != null && LicenseExpiresOn != null && LicenseExpiresOn > DateTime.UtcNow && LicenseError == null; diff --git a/Disco.Data/Migrations/202412180604170_DBv25.cs b/Disco.Data/Migrations/202412180604170_DBv25.cs index 42d55b0c..cf37ffd1 100644 --- a/Disco.Data/Migrations/202412180604170_DBv25.cs +++ b/Disco.Data/Migrations/202412180604170_DBv25.cs @@ -10,6 +10,10 @@ namespace Disco.Data.Migrations AlterColumn("dbo.UserAttachments", "Comments", c => c.String(maxLength: 500)); AlterColumn("dbo.JobAttachments", "Comments", c => c.String(maxLength: 500)); AlterColumn("dbo.DeviceAttachments", "Comments", c => c.String(maxLength: 500)); + + Sql("DELETE [dbo].[Configuration] WHERE [Scope]='System' AND [Key] IN ('ActivatedOn', 'ActivationId', 'LicenseExpiresOn')"); + Sql("DELETE [dbo].[Configuration] WHERE [Scope]='DocFill' AND [Key] IN ('LicenseExpiresOn')"); + Sql("DELETE [dbo].[Configuration] WHERE [Scope]='JobPreferences' AND [Key] IN ('LastExportDate')"); } public override void Down() diff --git a/Disco.Models/Disco.Models.csproj b/Disco.Models/Disco.Models.csproj index 759aaa29..976e9e2b 100644 --- a/Disco.Models/Disco.Models.csproj +++ b/Disco.Models/Disco.Models.csproj @@ -80,6 +80,8 @@ + + diff --git a/Disco.Models/Services/Interop/DiscoServices/Activation/CallbackModel.cs b/Disco.Models/Services/Interop/DiscoServices/Activation/CallbackModel.cs new file mode 100644 index 00000000..98bcd91d --- /dev/null +++ b/Disco.Models/Services/Interop/DiscoServices/Activation/CallbackModel.cs @@ -0,0 +1,11 @@ +using System; + +namespace Disco.Models.Services.Interop.DiscoServices.Activation +{ + public class CallbackModel + { + public Guid DeploymentId { get; set; } + public Guid CorrelationId { get; set; } + public string User { get; set; } + } +} diff --git a/Disco.Models/Services/Interop/DiscoServices/Activation/ChallengeModel.cs b/Disco.Models/Services/Interop/DiscoServices/Activation/ChallengeModel.cs new file mode 100644 index 00000000..35a94309 --- /dev/null +++ b/Disco.Models/Services/Interop/DiscoServices/Activation/ChallengeModel.cs @@ -0,0 +1,15 @@ +using System; + +namespace Disco.Models.Services.Interop.DiscoServices.Activation +{ + public class ChallengeModel + { + public byte[] Key { get; set; } + public Guid ActivationId { get; set; } + public string UserId { get; set; } + public long TimeStamp { get; set; } + public byte[] ChallengeResponse { get; set; } + public byte[] ChallengeResponseIv { get; set; } + public string RedirectUrl { get; set; } + } +} diff --git a/Disco.Services/Disco.Services.csproj b/Disco.Services/Disco.Services.csproj index 343b38cf..48bf458f 100644 --- a/Disco.Services/Disco.Services.csproj +++ b/Disco.Services/Disco.Services.csproj @@ -374,9 +374,12 @@ + + + @@ -536,6 +539,9 @@ TextTemplatingFileGenerator Claims.cs + + DiscoIct.OnlineServices.Activation.key + @@ -561,10 +567,11 @@ - if not exist "$(TargetDir)x86" md "$(TargetDir)x86" - xcopy /s /y "$(SolutionDir)packages\Microsoft.SqlServer.Compact.4.0.8876.1\NativeBinaries\x86\*.*" "$(TargetDir)x86" - if not exist "$(TargetDir)amd64" md "$(TargetDir)amd64" - xcopy /s /y "$(SolutionDir)packages\Microsoft.SqlServer.Compact.4.0.8876.1\NativeBinaries\amd64\*.*" "$(TargetDir)amd64" + if not exist "$(TargetDir)x86" md "$(TargetDir)x86" + xcopy /s /y "$(SolutionDir)packages\Microsoft.SqlServer.Compact.4.0.8876.1\NativeBinaries\x86\*.*" "$(TargetDir)x86" + if not exist "$(TargetDir)amd64" md "$(TargetDir)amd64" + xcopy /s /y "$(SolutionDir)packages\Microsoft.SqlServer.Compact.4.0.8876.1\NativeBinaries\amd64\*.*" "$(TargetDir)amd64" + diff --git a/Disco.Services/Extensions/DateTimeExtensions.cs b/Disco.Services/Extensions/DateTimeExtensions.cs index 74a722e1..488396b0 100644 --- a/Disco.Services/Extensions/DateTimeExtensions.cs +++ b/Disco.Services/Extensions/DateTimeExtensions.cs @@ -123,7 +123,7 @@ namespace Disco { var epoc = new DateTime(unixEpocOffset, DateTimeKind.Utc); var offset = d.ToUniversalTime() - epoc; - return offset.Ticks / 10000; + return offset.Ticks / TimeSpan.TicksPerMillisecond; } public static long? ToUnixEpoc(this DateTime? d) { @@ -132,6 +132,11 @@ namespace Disco else return null; } + public static DateTime FromUnixEpoc(this long d) + { + var epoc = new DateTime(unixEpocOffset, DateTimeKind.Utc); + return epoc.AddMilliseconds(d); + } public static string ToISO8601(this DateTime d) { diff --git a/Disco.Services/Interop/DiscoServices/Activation.key b/Disco.Services/Interop/DiscoServices/Activation.key new file mode 100644 index 0000000000000000000000000000000000000000..930f37099c4b1fa9b3b22fadf88d4528a0c5e0a5 GIT binary patch literal 140 zcmV;70CWFELrXP600000?IG%)W0GbGwCHl$&dA7L?{%2I)_fL1?}uZ5T4gr_i5NpG zT$LLQHUoD;v`b8BW!EB6;buxfwtU$3Olq5&tpI-faAFu6bb2^jBd6seWszy29;21g uEB0Mdc%pvaZ{pi0Y$l)LX6Of5Nw@31ruqnJdsx2r^eN`Q)8D;nu8{s6Q$zFs literal 0 HcmV?d00001 diff --git a/Disco.Services/Interop/DiscoServices/ActivationCleanupTask.cs b/Disco.Services/Interop/DiscoServices/ActivationCleanupTask.cs new file mode 100644 index 00000000..df27b193 --- /dev/null +++ b/Disco.Services/Interop/DiscoServices/ActivationCleanupTask.cs @@ -0,0 +1,42 @@ +using Disco.Data.Repository; +using Disco.Services.Tasks; +using Quartz; +using System; + +namespace Disco.Services.Interop.DiscoServices +{ + public class ActivationCleanupTask : ScheduledTask + { + public override string TaskName { get { return "Activation Cleanup"; } } + public override bool LogExceptionsOnly => true; + + public override void InitalizeScheduledTask(DiscoDataContext Database) + { + var service = new ActivationService(Database); + + if (service.RequiresCleanup) + { + // Trigger in 1 + 0-29 minutes + var rng = new Random(); + var delay = rng.Next(30) + 1; + + TriggerBuilder triggerBuilder = TriggerBuilder.Create() + .StartAt(DateTimeOffset.Now.AddMinutes(delay)); + + ScheduleTask(triggerBuilder); + + base.InitalizeScheduledTask(Database); + } + } + + protected override void ExecuteTask() + { + using (var database = new DiscoDataContext()) + { + var service = new ActivationService(database); + service.CleanupExpiredActivations(); + } + } + + } +} diff --git a/Disco.Services/Interop/DiscoServices/ActivationService.cs b/Disco.Services/Interop/DiscoServices/ActivationService.cs new file mode 100644 index 00000000..735c6586 --- /dev/null +++ b/Disco.Services/Interop/DiscoServices/ActivationService.cs @@ -0,0 +1,434 @@ +using Disco.Data.Repository; +using Disco.Models.Repository; +using Disco.Models.Services.Interop.DiscoServices.Activation; +using Disco.Services.Interop.VicEduDept; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +namespace Disco.Services.Interop.DiscoServices +{ + public class ActivationService + { + private static readonly byte[] onlineServicesActivationKey; + internal const string baseUrl = "https://activate.discoict.com.au"; + private readonly DiscoDataContext database; + + static ActivationService() + { + using (var resourceStream = typeof(ActivationService).Assembly.GetManifestResourceStream("DiscoIct.OnlineServices.Activation.key")) + { + var key = new byte[resourceStream.Length]; + resourceStream.Read(key, 0, key.Length); + onlineServicesActivationKey = key; + } + } + + public ActivationService(DiscoDataContext database) + { + this.database = database; + } + + public string GetDataStoreLocation => Path.Combine(database.DiscoConfiguration.DataStoreLocation, "Activations"); + public bool RequiresCleanup => Directory.Exists(GetDataStoreLocation); + + public string GetCallbackUrl() + => $"{baseUrl}/api/callback"; + + /// + /// Begin the activation process + /// + /// A redirect URL where the user can attempt activation + public async Task BeginActivation(User user, string completeUrl, string finalUrl) + { + if (database.DiscoConfiguration.IsActivated) + throw new InvalidOperationException("Deployment is already activated"); + + // generate activation key + var (privateKey, publicKey) = GenerateActivationKey(); + + // get machine ip addresses + var networkInterfaces = GetMachineIpAddresses(); + + var vicSchool = VicSmart.WhoAmI(); + + // get challenge + ChallengeResponse challenge; + using (var httpClient = new HttpClient()) + { + httpClient.BaseAddress = new Uri(baseUrl); + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + var body = new ChallengeRequest() + { + DeploymentId = Guid.Parse(database.DiscoConfiguration.DeploymentId), + DeploymentVersion = typeof(ActivationService).Assembly.GetName().Version.ToString(4), + OrganisationName = database.DiscoConfiguration.OrganisationName, + PublicKey = publicKey, + UserId = user.UserId, + UserName = user.DisplayName, + UserEmail = user.EmailAddress, + CompleteUrl = completeUrl, + DeploymentIpAddresses = string.Join(";", networkInterfaces), + VicGovSchoolId = vicSchool?.Item1, + VicGovSchoolName = vicSchool?.Item2, + }; + var requestJson = JsonConvert.SerializeObject(body); + + using (var request = new ByteArrayContent(System.Text.Encoding.UTF8.GetBytes(requestJson))) + { + request.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + var response = await httpClient.PostAsync("/api/challenge", request); + + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(); + + challenge = JsonConvert.DeserializeObject(responseJson); + } + } + + // validate signature + if (!ValidateSignature(challenge)) + throw new InvalidOperationException("Invalid challenge signature"); + + // decrypt challenge token + var token = Decrypt(privateKey, challenge.ChallengeIv, challenge.TimeStamp, challenge.Challenge); + if (token.Length != 32) + throw new InvalidOperationException("Unexpected challenge length"); + // invert token + for (int i = 0; i < token.Length; i++) + token[i] = (byte)~token[i]; + var challengeResponseIv = new byte[16]; + using (var rng = RandomNumberGenerator.Create()) + rng.GetBytes(challengeResponseIv); + // encrypt token + var challengeResponse = Encrypt(privateKey, challengeResponseIv, challenge.TimeStamp, token); + if (challengeResponse.Length != 48) + throw new InvalidOperationException("Unexpected challenge response length"); + + var result = new ChallengeModel + { + Key = privateKey, + ActivationId = challenge.ActivationId, + UserId = user.UserId, + TimeStamp = challenge.TimeStamp, + ChallengeResponse = challengeResponse, + ChallengeResponseIv = challengeResponseIv, + RedirectUrl = $"{baseUrl}/", + }; + + // store activation + var datastore = GetDataStoreLocation; + if (!Directory.Exists(datastore)) + Directory.CreateDirectory(datastore); + var resultJson = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(result)); + var protectedResult = ProtectedData.Protect(resultJson, challenge.ActivationId.ToByteArray(), DataProtectionScope.LocalMachine); + var datastoreFile = Path.Combine(datastore, $"{challenge.ActivationId:N}.bin"); + File.WriteAllBytes(datastoreFile, protectedResult); + + return result; + } + + public async Task CompleteActivation(Guid activationId, byte[] challenge, byte[] challengeIv, byte[] signature) + { + if (database.DiscoConfiguration.IsActivated) + throw new InvalidOperationException("Deployment is already activated"); + + // validate signature + if (!ValidateSignature(activationId, challenge, challengeIv, signature)) + throw new InvalidOperationException("Invalid signature"); + + // load activation + var datastoreFile = Path.Combine(GetDataStoreLocation, $"{activationId:N}.bin"); + if (!File.Exists(datastoreFile)) + throw new InvalidOperationException("Activation not found"); + var protectedActivation = File.ReadAllBytes(datastoreFile); + var activationJson = ProtectedData.Unprotect(protectedActivation, activationId.ToByteArray(), DataProtectionScope.LocalMachine); + var activation = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(activationJson)); + + // decrypt challenge token + var token = Decrypt(activation.Key, challengeIv, activation.TimeStamp, challenge); + if (token.Length != 32) + throw new InvalidOperationException("Unexpected challenge length"); + // reverse token + Array.Reverse(token); + var challengeResponseIv = new byte[16]; + using (var rng = RandomNumberGenerator.Create()) + rng.GetBytes(challengeResponseIv); + // encrypt token + var challengeResponse = Encrypt(activation.Key, challengeResponseIv, activation.TimeStamp, token); + if (challengeResponse.Length != 48) + throw new InvalidOperationException("Unexpected challenge response length"); + + var responseSignature = Sign(activation.Key, activationId, challengeResponse, challengeResponseIv); + + using (var httpClient = new HttpClient()) + { + httpClient.BaseAddress = new Uri(baseUrl); + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + var body = new CompleteRequest() + { + DeploymentId = Guid.Parse(database.DiscoConfiguration.DeploymentId), + ActivationId = activationId, + ChallengeResponse = challengeResponse, + ChallengeResponseIv = challengeResponseIv, + Signature = responseSignature, + }; + var requestJson = JsonConvert.SerializeObject(body); + + using (var request = new ByteArrayContent(Encoding.UTF8.GetBytes(requestJson))) + { + request.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + var response = await httpClient.PostAsync("/api/complete", request); + + response.EnsureSuccessStatusCode(); + + database.DiscoConfiguration.ActivationId = activationId; + database.DiscoConfiguration.ActivatedOn = DateTime.Now; + database.DiscoConfiguration.ActivatedBy = activation.UserId; + database.DiscoConfiguration.ActivationKey = activation.Key; + database.SaveChanges(); + + OnlineServicesAuthentication.UpdateActivation(database); + } + } + + } + + public void CleanupExpiredActivations() + { + var dataStore = GetDataStoreLocation; + if (Directory.Exists(dataStore)) + { + if (database.DiscoConfiguration.IsActivated) + { + Directory.Delete(dataStore, true); + } + else + { + var threshold = DateTime.Now.AddDays(-14); + foreach (var file in Directory.EnumerateFiles(dataStore, "*.bin")) + { + if (File.GetCreationTime(file) < threshold) + File.Delete(file); + } + if (!Directory.EnumerateFileSystemEntries(dataStore).Any()) + Directory.Delete(dataStore); + } + } + } + + private static bool ValidateSignature(ChallengeResponse response) + { + var stream = new MemoryStream(); + stream.Write(response.ActivationId.ToByteArray(), 0, 16); + stream.Write(BitConverter.GetBytes(response.TimeStamp), 0, 8); + stream.Write(response.Challenge, 0, response.Challenge.Length); + stream.Write(response.ChallengeIv, 0, response.ChallengeIv.Length); + + return ValidateSignature(stream.ToArray(), response.Signature); + } + + private static bool ValidateSignature(Guid activationId, byte[] challenge, byte[] challengeIv, byte[] signature) + { + var stream = new MemoryStream(); + stream.Write(activationId.ToByteArray(), 0, 16); + stream.Write(challenge, 0, challenge.Length); + stream.Write(challengeIv, 0, challengeIv.Length); + return ValidateSignature(stream.ToArray(), signature); + } + + private static bool ValidateSignature(byte[] bytes, byte[] signature) + { + byte[] hash; + using (var hasher = SHA256.Create()) + { + hash = hasher.ComputeHash(bytes); + } + + using (var serverKey = CngKey.Import(onlineServicesActivationKey, CngKeyBlobFormat.EccPublicBlob)) + { + using (var ecdsa = new ECDsaCng(serverKey)) + { + ecdsa.HashAlgorithm = CngAlgorithm.Sha256; + return ecdsa.VerifyHash(hash, signature); + } + } + } + + private static byte[] Sign(byte[] privateKey, Guid activationId, byte[] challengeResponse, byte[] challengeResponseIv) + { + using (var stream = new MemoryStream()) + { + stream.Write(activationId.ToByteArray(), 0, 16); + stream.Write(challengeResponse, 0, challengeResponse.Length); + stream.Write(challengeResponseIv, 0, challengeResponseIv.Length); + byte[] hash; + using (var hasher = SHA256.Create()) + { + hash = hasher.ComputeHash(stream.ToArray()); + return SignHash(privateKey, hash); + } + } + } + + internal static byte[] SignHash(byte[] privateKey, byte[] hash) + { + using (var key = CngKey.Import(privateKey, CngKeyBlobFormat.EccPrivateBlob)) + { + using (var ecdsa = new ECDsaCng(key)) + { + ecdsa.HashAlgorithm = CngAlgorithm.Sha256; + return ecdsa.SignHash(hash); + } + } + } + + private static byte[] Encrypt(byte[] privateKey, byte[] iv, long timeStamp, byte[] data) + { + var key = DeriveEncryptionKey(privateKey, iv, timeStamp); + + using (var aes = Aes.Create()) + { + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using (var encryptor = aes.CreateEncryptor()) + { + var ms = new MemoryStream(); + using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) + { + cs.Write(data, 0, data.Length); + } + return ms.ToArray(); + } + } + } + + private static byte[] Decrypt(byte[] privateKey, byte[] iv, long timeStamp, byte[] data) + { + var key = DeriveEncryptionKey(privateKey, iv, timeStamp); + + using (var aes = Aes.Create()) + { + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using (var decryptor = aes.CreateDecryptor()) + { + var ms = new MemoryStream(data); + using (var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read)) + { + var output = new MemoryStream(); + cs.CopyTo(output); + return output.ToArray(); + } + } + } + } + + private static byte[] DeriveEncryptionKey(byte[] privateKey, byte[] iv, long timeStamp) + { + using (var serverKey = CngKey.Import(onlineServicesActivationKey, CngKeyBlobFormat.EccPublicBlob)) + { + using (var clientKey = CngKey.Import(privateKey, CngKeyBlobFormat.EccPrivateBlob)) + { + using (var serverEcdh = new ECDiffieHellmanCng(serverKey)) + { + using (var ecdh = new ECDiffieHellmanCng(clientKey)) + { + return ecdh.DeriveKeyFromHash(serverEcdh.PublicKey, HashAlgorithmName.SHA256, iv, BitConverter.GetBytes(timeStamp)); + } + } + } + } + } + + private static (byte[] privateKey, byte[] publicKey) GenerateActivationKey() + { + using (var key = CngKey.Create(CngAlgorithm.ECDiffieHellmanP521, null, new CngKeyCreationParameters + { + ExportPolicy = CngExportPolicies.AllowPlaintextExport, + KeyUsage = CngKeyUsages.KeyAgreement, + })) + { + var privateKey = key.Export(CngKeyBlobFormat.EccPrivateBlob); + var publicKey = key.Export(CngKeyBlobFormat.EccPublicBlob); + + return (privateKey, publicKey); + } + } + + private static List GetMachineIpAddresses() + { + var ipAddresses = new List(); + foreach (var networkInterface in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces()) + { + foreach (var address in networkInterface.GetIPProperties().UnicastAddresses) + { + if (address.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + ipAddresses.Add(address.Address.ToString()); + } + } + return ipAddresses; + } + + private class ChallengeRequest + { + public Guid DeploymentId { get; set; } + [MaxLength(20)] + public string DeploymentVersion { get; set; } + [MaxLength(200)] + public string OrganisationName { get; set; } + [MaxLength(158)] + public byte[] PublicKey { get; set; } + [StringLength(50)] + public string UserId { get; set; } + [StringLength(150)] + public string UserName { get; set; } + [StringLength(150)] + public string UserEmail { get; set; } + [StringLength(200)] + public string CompleteUrl { get; set; } + [StringLength(200)] + public string DeploymentIpAddresses { get; set; } + public string VicGovSchoolId { get; set; } + [StringLength(150)] + public string VicGovSchoolName { get; set; } + } + + private class ChallengeResponse + { + public Guid ActivationId { get; set; } + public long TimeStamp { get; set; } + public byte[] Challenge { get; set; } + public byte[] ChallengeIv { get; set; } + public byte[] Signature { get; set; } + } + + private class CompleteRequest + { + public Guid DeploymentId { get; set; } + public Guid ActivationId { get; set; } + public byte[] ChallengeResponse { get; set; } + public byte[] ChallengeResponseIv { get; set; } + public byte[] Signature { get; set; } + } + } +} diff --git a/Disco.Services/Interop/DiscoServices/OnlineServicesAuthentication.cs b/Disco.Services/Interop/DiscoServices/OnlineServicesAuthentication.cs new file mode 100644 index 00000000..313c81c5 --- /dev/null +++ b/Disco.Services/Interop/DiscoServices/OnlineServicesAuthentication.cs @@ -0,0 +1,167 @@ +using Disco.Data.Repository; +using Newtonsoft.Json; +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Disco.Services.Interop.DiscoServices +{ + public static class OnlineServicesAuthentication + { + private static SemaphoreSlim semaphore = new SemaphoreSlim(1, 1); + private static readonly Guid deploymentId; + private static Guid? activationId; + private static byte[] key; + private static string token; + private static DateTime? tokenExpires; + + static OnlineServicesAuthentication() + { + using (var database = new DiscoDataContext()) + { + deploymentId = Guid.Parse(database.DiscoConfiguration.DeploymentId); + UpdateActivation(database); + } + } + + public static bool IsActivated => activationId.HasValue; + + public static string GetToken() + => GetTokenAsync().Result; + + public async static Task GetTokenAsync() + { + var localExpires = tokenExpires; + var localToken = token; + if (tokenExpires != null && tokenExpires.Value > DateTime.UtcNow && localToken != null) + return localToken; + + if (!IsActivated) + throw new InvalidOperationException("Not activated"); + + await semaphore.WaitAsync(); + try + { + localExpires = tokenExpires; + localToken = token; + if (tokenExpires != null && tokenExpires.Value < DateTime.UtcNow && localToken != null) + return localToken; + + if (!IsActivated) + throw new InvalidOperationException("Not activated"); + + using (var httpClient = new HttpClient()) + { + httpClient.BaseAddress = new Uri(ActivationService.baseUrl); + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + var timeStamp = DateTime.UtcNow.ToUnixEpoc(); + var iv = new byte[32]; + using (var rng = RandomNumberGenerator.Create()) + rng.GetBytes(iv); + + var dataStream = new MemoryStream(16 + 16 + 8 + iv.Length); + dataStream.Write(deploymentId.ToByteArray(), 0, 16); + dataStream.Write(activationId.Value.ToByteArray(), 0, 16); + dataStream.Write(BitConverter.GetBytes(timeStamp), 0, 8); + dataStream.Write(iv, 0, iv.Length); + byte[] hash; + using (var hasher = SHA256.Create()) + hash = hasher.ComputeHash(dataStream.ToArray()); + + var signature = ActivationService.SignHash(key, hash); + + var body = new AuthenticationRequest() + { + DeploymentId = deploymentId, + ActivationId = activationId.Value, + TimeStamp = timeStamp, + IV = iv, + Signature = signature, + }; + var requestJson = JsonConvert.SerializeObject(body); + + using (var request = new ByteArrayContent(Encoding.UTF8.GetBytes(requestJson))) + { + request.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + + var response = await httpClient.PostAsync("/api/authenticate", request); + + if (response.StatusCode == HttpStatusCode.OK || response.StatusCode == HttpStatusCode.BadRequest) + { + var responseJson = await response.Content.ReadAsStringAsync(); + var authResponse = JsonConvert.DeserializeObject(responseJson); + + if (authResponse == null) + throw new InvalidOperationException("Failed to authenticate (empty response)"); + + if (!authResponse.Success) + throw new InvalidOperationException($"Failed to authenticate ({authResponse.ErrorMessage})"); + + token = authResponse.Token; + tokenExpires = DateTimeExtensions.FromUnixEpoc(authResponse.Expiry.Value).AddMinutes(-5); + + return token; + } + else + throw new InvalidOperationException($"Failed to authenticate ({response.StatusCode} {response.ReasonPhrase})"); + } + } + } + finally + { + semaphore.Release(); + } + } + + internal static void UpdateActivation(DiscoDataContext database) + { + semaphore.Wait(); + try + { + var config = database.DiscoConfiguration; + if (config.IsActivated) + { + activationId = config.ActivationId; + key = config.ActivationKey; + token = null; + tokenExpires = null; + } + else + { + activationId = null; + key = null; + token = null; + tokenExpires = null; + } + } + finally + { + semaphore.Release(); + } + } + + private class AuthenticationRequest + { + public Guid DeploymentId { get; set; } + public Guid ActivationId { get; set; } + public long TimeStamp { get; set; } + public byte[] IV { get; set; } + public byte[] Signature { get; set; } + } + + private class AuthenticationResponse + { + public bool Success { get; set; } + public string Token { get; set; } + public long? Expiry { get; set; } + public string ErrorMessage { get; set; } + } + } +} diff --git a/Disco.Web/Areas/API/Controllers/ActivationController.cs b/Disco.Web/Areas/API/Controllers/ActivationController.cs new file mode 100644 index 00000000..6310bc1f --- /dev/null +++ b/Disco.Web/Areas/API/Controllers/ActivationController.cs @@ -0,0 +1,54 @@ +using Disco.Services.Authorization; +using Disco.Services.Interop.DiscoServices; +using Disco.Services.Web; +using Disco.Web.Areas.API.Models.Activation; +using System; +using System.Threading.Tasks; +using System.Web.Mvc; + +namespace Disco.Web.Areas.API.Controllers +{ + + [DiscoAuthorize(Claims.DiscoAdminAccount)] + public partial class ActivationController : AuthorizedDatabaseController + { + [HttpPost] + public virtual ActionResult TestCallback(CallbackModel model) + { + return this.PrecompiledPartialView(model); + } + + [HttpPost, ValidateAntiForgeryToken] + public virtual async Task Begin() + { + var service = new ActivationService(Database); + + var challengeModel = await service.BeginActivation(CurrentUser, Url.ActionAbsolute(MVC.API.Activation.Complete()), Url.ActionAbsolute(MVC.Config.SystemConfig.Index())); + + var model = new BeginModel() + { + ActivationId = challengeModel.ActivationId, + ChallengeResponse = Convert.ToBase64String(challengeModel.ChallengeResponse), + ChallengeResponseIv = Convert.ToBase64String(challengeModel.ChallengeResponseIv), + RedirectUrl = challengeModel.RedirectUrl + }; + + return View(model); + } + + [HttpGet] + public virtual async Task Complete(Guid activationId, string challenge, string challengeIv, string signature) + { + var service = new ActivationService(Database); + + var challengeBytes = Convert.FromBase64String(challenge.Replace('-', '+').Replace('_', '/')); + var challengeIvBytes = Convert.FromBase64String(challengeIv.Replace('-', '+').Replace('_', '/')); + var signatureBytes = Convert.FromBase64String(signature.Replace('-', '+').Replace('_', '/')); + + await service.CompleteActivation(activationId, challengeBytes, challengeIvBytes, signatureBytes); + + return RedirectToAction(MVC.Config.SystemConfig.Index()); + } + + } +} diff --git a/Disco.Web/Areas/API/Models/Activation/BeginModel.cs b/Disco.Web/Areas/API/Models/Activation/BeginModel.cs new file mode 100644 index 00000000..bbaa2489 --- /dev/null +++ b/Disco.Web/Areas/API/Models/Activation/BeginModel.cs @@ -0,0 +1,12 @@ +using System; + +namespace Disco.Web.Areas.API.Models.Activation +{ + public class BeginModel + { + public Guid ActivationId { get; set; } + public string ChallengeResponse { get; set; } + public string ChallengeResponseIv { get; set; } + public string RedirectUrl { get; set; } + } +} diff --git a/Disco.Web/Areas/API/Models/Activation/CallbackModel.cs b/Disco.Web/Areas/API/Models/Activation/CallbackModel.cs new file mode 100644 index 00000000..1d5d5105 --- /dev/null +++ b/Disco.Web/Areas/API/Models/Activation/CallbackModel.cs @@ -0,0 +1,13 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace Disco.Web.Areas.API.Models.Activation +{ + public class CallbackModel + { + public Guid DeploymentId { get; set; } + public Guid CorrelationId { get; set; } + [StringLength(50)] + public string UserId { get; set; } + } +} diff --git a/Disco.Web/Areas/API/Views/Activation/Begin.cshtml b/Disco.Web/Areas/API/Views/Activation/Begin.cshtml new file mode 100644 index 00000000..3dddca79 --- /dev/null +++ b/Disco.Web/Areas/API/Views/Activation/Begin.cshtml @@ -0,0 +1,21 @@ +@model Disco.Web.Areas.API.Models.Activation.BeginModel +@{ + Authorization.Require(Claims.DiscoAdminAccount); + + ViewBag.Title = Html.ToBreadcrumb("Configuration", MVC.Config.Config.Index(), "System", MVC.Config.SystemConfig.Index(), "Activate"); +} + +
+
+

Redirecting to Disco ICT Online Services

+
+
+ +
+ + + +
+ diff --git a/Disco.Web/Areas/API/Views/Activation/Begin.generated.cs b/Disco.Web/Areas/API/Views/Activation/Begin.generated.cs new file mode 100644 index 00000000..a24ed413 --- /dev/null +++ b/Disco.Web/Areas/API/Views/Activation/Begin.generated.cs @@ -0,0 +1,140 @@ +#pragma warning disable 1591 +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Disco.Web.Areas.API.Views.Activation +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net; + using System.Text; + using System.Web; + using System.Web.Helpers; + using System.Web.Mvc; + using System.Web.Mvc.Ajax; + using System.Web.Mvc.Html; + using System.Web.Routing; + using System.Web.Security; + using System.Web.UI; + using System.Web.WebPages; + using Disco; + using Disco.Models.Repository; + using Disco.Services; + using Disco.Services.Authorization; + using Disco.Services.Web; + using Disco.Web; + using Disco.Web.Extensions; + + [System.CodeDom.Compiler.GeneratedCodeAttribute("RazorGenerator", "2.0.0.0")] + [System.Web.WebPages.PageVirtualPathAttribute("~/Areas/API/Views/Activation/Begin.cshtml")] + public partial class Begin : Disco.Services.Web.WebViewPage + { + public Begin() + { + } + public override void Execute() + { + + #line 2 "..\..\Areas\API\Views\Activation\Begin.cshtml" + + Authorization.Require(Claims.DiscoAdminAccount); + + ViewBag.Title = Html.ToBreadcrumb("Configuration", MVC.Config.Config.Index(), "System", MVC.Config.SystemConfig.Index(), "Activate"); + + + #line default + #line hidden +WriteLiteral("\r\n\r\n\r\n \r\n

Redirecting to Disco ICT Online Services

\r\n \r\n\r\n\r\n(Model.RedirectUrl + + #line default + #line hidden +, 498), false) +); + +WriteLiteral(" method=\"post\""); + +WriteLiteral(">\r\n (Model.ActivationId + + #line default + #line hidden +, 586), false) +); + +WriteLiteral(" />\r\n (Model.ChallengeResponse + + #line default + #line hidden +, 668), false) +); + +WriteLiteral(" />\r\n (Model.ChallengeResponseIv + + #line default + #line hidden +, 757), false) +); + +WriteLiteral(" />\r\n\r\n\r" + +"\n"); + + } + } +} +#pragma warning restore 1591 diff --git a/Disco.Web/Areas/API/Views/Activation/_ActivateCallback.cshtml b/Disco.Web/Areas/API/Views/Activation/_ActivateCallback.cshtml new file mode 100644 index 00000000..7512fdb3 --- /dev/null +++ b/Disco.Web/Areas/API/Views/Activation/_ActivateCallback.cshtml @@ -0,0 +1,17 @@ +@model Disco.Web.Areas.API.Models.Activation.CallbackModel +@{ + Layout = null; +} + + + + + + + + diff --git a/Disco.Web/Areas/API/Views/Activation/_ActivateCallback.generated.cs b/Disco.Web/Areas/API/Views/Activation/_ActivateCallback.generated.cs new file mode 100644 index 00000000..dcf94d58 --- /dev/null +++ b/Disco.Web/Areas/API/Views/Activation/_ActivateCallback.generated.cs @@ -0,0 +1,107 @@ +#pragma warning disable 1591 +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Disco.Web.Areas.API.Views.Activation +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net; + using System.Text; + using System.Web; + using System.Web.Helpers; + using System.Web.Mvc; + using System.Web.Mvc.Ajax; + using System.Web.Mvc.Html; + using System.Web.Routing; + using System.Web.Security; + using System.Web.UI; + using System.Web.WebPages; + using Disco; + using Disco.Models.Repository; + using Disco.Services; + using Disco.Services.Authorization; + using Disco.Services.Web; + using Disco.Web; + using Disco.Web.Extensions; + + [System.CodeDom.Compiler.GeneratedCodeAttribute("RazorGenerator", "2.0.0.0")] + [System.Web.WebPages.PageVirtualPathAttribute("~/Areas/API/Views/Activation/_ActivateCallback.cshtml")] + public partial class _ActivateCallback : Disco.Services.Web.WebViewPage + { + public _ActivateCallback() + { + } + public override void Execute() + { + + #line 2 "..\..\Areas\API\Views\Activation\_ActivateCallback.cshtml" + + Layout = null; + + + #line default + #line hidden +WriteLiteral("\r\n\r\n\r\n\r\n\r\n