feature: online activation
This commit is contained in:
@@ -374,9 +374,12 @@
|
||||
<Compile Include="Interop\ActiveDirectory\ADUserAccountControlFlags.cs" />
|
||||
<Compile Include="Interop\ActiveDirectory\Description.cs" />
|
||||
<Compile Include="Interop\ActiveDirectory\IADObject.cs" />
|
||||
<Compile Include="Interop\DiscoServices\ActivationCleanupTask.cs" />
|
||||
<Compile Include="Interop\DiscoServices\ActivationService.cs" />
|
||||
<Compile Include="Interop\DiscoServices\DiscoServiceHelpers.cs" />
|
||||
<Compile Include="Interop\DiscoServices\Jobs.cs" />
|
||||
<Compile Include="Interop\DiscoServices\LicenseValidationTask.cs" />
|
||||
<Compile Include="Interop\DiscoServices\OnlineServicesAuthentication.cs" />
|
||||
<Compile Include="Interop\DiscoServices\PluginLibrary.cs" />
|
||||
<Compile Include="Interop\DiscoServices\PluginLibraryUpdateTask.cs" />
|
||||
<Compile Include="Interop\IIS\PreserveIisBindingsTask.cs" />
|
||||
@@ -536,6 +539,9 @@
|
||||
<Generator>TextTemplatingFileGenerator</Generator>
|
||||
<LastGenOutput>Claims.cs</LastGenOutput>
|
||||
</None>
|
||||
<EmbeddedResource Include="Interop\DiscoServices\Activation.key">
|
||||
<LogicalName>DiscoIct.OnlineServices.Activation.key</LogicalName>
|
||||
</EmbeddedResource>
|
||||
<None Include="packages.config" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
@@ -561,10 +567,11 @@
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
<PropertyGroup>
|
||||
<PostBuildEvent>
|
||||
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"</PostBuildEvent>
|
||||
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"
|
||||
</PostBuildEvent>
|
||||
</PropertyGroup>
|
||||
<Import Project="..\packages\Microsoft.Bcl.Build.1.0.14\tools\Microsoft.Bcl.Build.targets" Condition="Exists('..\packages\Microsoft.Bcl.Build.1.0.14\tools\Microsoft.Bcl.Build.targets')" />
|
||||
<Target Name="EnsureBclBuildImported" BeforeTargets="BeforeBuild" Condition="'$(BclBuildImported)' == ''">
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Binary file not shown.
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
/// <summary>
|
||||
/// Begin the activation process
|
||||
/// </summary>
|
||||
/// <returns>A redirect URL where the user can attempt activation</returns>
|
||||
public async Task<ChallengeModel> 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<ChallengeResponse>(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<ChallengeModel>(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<string> GetMachineIpAddresses()
|
||||
{
|
||||
var ipAddresses = new List<string>();
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string> 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<AuthenticationResponse>(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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user