├── .gitignore ├── LICENSE.md ├── README.md ├── build ├── build-all.ps1 ├── build.ps1 ├── nuget.exe └── zip.ps1 └── src ├── .editorconfig ├── Certera.Core ├── Certera.Core.csproj ├── Concurrency │ └── NamedLocker.cs ├── Extensions │ ├── DateTimeExtensions.cs │ ├── LinqExtensions.cs │ ├── StringExtensions.cs │ └── X509Certificate2Extensions.cs ├── Helpers │ ├── DomainParser.cs │ ├── EnvironmentVariableHelper.cs │ └── OcspClient.cs └── Notifications │ ├── MailSender.cs │ ├── MailSenderInfo.cs │ ├── TemplateManager.cs │ └── Templates │ ├── NotificationCertificateAcquisitionFailureEmail.html │ ├── NotificationCertificateAcquisitionFailureSlack.json │ ├── NotificationCertificateChangeEmail.html │ ├── NotificationCertificateChangeSlack.json │ ├── NotificationCertificateExpirationEmail.html │ └── NotificationCertificateExpirationSlack.json ├── Certera.Data ├── ApiKeyGenerator.cs ├── ApplicationUser.partial.cs ├── Certera.Data.csproj ├── DataContext.cs ├── DataContext.partial.cs ├── Migrations │ ├── 20190908034945_AspnetIdentity.Designer.cs │ ├── 20190908034945_AspnetIdentity.cs │ ├── 20190926043004_DataModels.Designer.cs │ ├── 20190926043004_DataModels.cs │ ├── 20200107001128_IndividualApiKey.Designer.cs │ ├── 20200107001128_IndividualApiKey.cs │ ├── 20200108175150_SetApiKeyValues.Designer.cs │ ├── 20200108175150_SetApiKeyValues.cs │ ├── 20200303222607_SlackNotification.Designer.cs │ ├── 20200303222607_SlackNotification.cs │ └── DataContextModelSnapshot.cs ├── Models │ ├── AcmeAccount.cs │ ├── AcmeCertificate.cs │ ├── AcmeOrder.cs │ ├── AcmeRequest.cs │ ├── Domain.cs │ ├── DomainCertificate.cs │ ├── DomainCertificateChangeEvent.cs │ ├── DomainScan.cs │ ├── Key.cs │ ├── KeyHistory.cs │ ├── NotificationSetting.cs │ ├── Setting.cs │ ├── UserConfiguration.cs │ └── UserNotification.cs └── Views │ └── TrackedCertificate.cs ├── Certera.Web ├── AcmeProviders │ └── CertesAcmeProvider.cs ├── Authentication │ ├── ApiKeyAuthorizeAttribute.cs │ ├── CertApiKeyAuthenticationHandler.cs │ └── KeyApiKeyAuthenticationHandler.cs ├── Certera.Web.csproj ├── Controllers │ ├── CertificateController.cs │ ├── KeyController.cs │ ├── TestController.cs │ └── WellKnownController.cs ├── Extensions │ ├── ConnectionExtensions.cs │ └── FormFileExtensions.cs ├── IdentityHostingStartup.cs ├── Middleware │ ├── AllowedRemoteIpMiddleware.cs │ ├── SetupAcmeCertMiddleware.cs │ └── SetupMiddleware.cs ├── Options │ ├── AllowedRemoteIPAddresses.cs │ ├── DnsServers.cs │ ├── HttpServer.cs │ ├── Setup.cs │ └── WriteableOptions.cs ├── Pages │ ├── Account │ │ ├── AccessDenied.cshtml │ │ ├── AccessDenied.cshtml.cs │ │ ├── ForgotPassword.cshtml │ │ ├── ForgotPassword.cshtml.cs │ │ ├── ForgotPasswordConfirmation.cshtml │ │ ├── ForgotPasswordConfirmation.cshtml.cs │ │ ├── Lockout.cshtml │ │ ├── Lockout.cshtml.cs │ │ ├── Login.cshtml │ │ ├── Login.cshtml.cs │ │ ├── Login2fa.cshtml │ │ ├── Login2fa.cshtml.cs │ │ ├── LoginRecoveryCode.cshtml │ │ ├── LoginRecoveryCode.cshtml.cs │ │ ├── Logout.cshtml │ │ ├── Logout.cshtml.cs │ │ ├── Manage │ │ │ ├── ChangePassword.cshtml │ │ │ ├── ChangePassword.cshtml.cs │ │ │ ├── Disable2fa.cshtml │ │ │ ├── Disable2fa.cshtml.cs │ │ │ ├── EnableAuthenticator.cshtml │ │ │ ├── EnableAuthenticator.cshtml.cs │ │ │ ├── GenerateRecoveryCodes.cshtml │ │ │ ├── GenerateRecoveryCodes.cshtml.cs │ │ │ ├── Index.cshtml │ │ │ ├── Index.cshtml.cs │ │ │ ├── ManageNavPages.cs │ │ │ ├── ResetAuthenticator.cshtml │ │ │ ├── ResetAuthenticator.cshtml.cs │ │ │ ├── ShowRecoveryCodes.cshtml │ │ │ ├── ShowRecoveryCodes.cshtml.cs │ │ │ ├── TwoFactorAuthentication.cshtml │ │ │ ├── TwoFactorAuthentication.cshtml.cs │ │ │ ├── _Layout.cshtml │ │ │ ├── _ManageNav.cshtml │ │ │ ├── _StatusMessage.cshtml │ │ │ ├── _ViewImports.cshtml │ │ │ └── _ViewStart.cshtml │ │ ├── ResetPassword.cshtml │ │ ├── ResetPassword.cshtml.cs │ │ ├── ResetPasswordConfirmation.cshtml │ │ ├── ResetPasswordConfirmation.cshtml.cs │ │ ├── _Layout.cshtml │ │ ├── _ViewImports.cshtml │ │ └── _ViewStart.cshtml │ ├── Acme │ │ └── Accounts │ │ │ ├── Create.cshtml │ │ │ ├── Create.cshtml.cs │ │ │ ├── Delete.cshtml │ │ │ ├── Delete.cshtml.cs │ │ │ ├── Edit.cshtml │ │ │ ├── Edit.cshtml.cs │ │ │ ├── Index.cshtml │ │ │ └── Index.cshtml.cs │ ├── Certificates │ │ ├── Create.cshtml │ │ ├── Create.cshtml.cs │ │ ├── Delete.cshtml │ │ ├── Delete.cshtml.cs │ │ ├── Edit.cshtml │ │ ├── Edit.cshtml.cs │ │ ├── History.cshtml │ │ ├── History.cshtml.cs │ │ ├── Index.cshtml │ │ ├── Index.cshtml.cs │ │ ├── Request.cshtml │ │ └── Request.cshtml.cs │ ├── Components │ │ ├── ChallengeSelect │ │ │ ├── default.cshtml │ │ │ └── default.cshtml.cs │ │ └── KeySelect │ │ │ ├── default.cshtml │ │ │ └── default.cshtml.cs │ ├── Error.cshtml │ ├── Error.cshtml.cs │ ├── Index.cshtml │ ├── Index.cshtml.cs │ ├── Keys │ │ ├── Create.cshtml │ │ ├── Create.cshtml.cs │ │ ├── Delete.cshtml │ │ ├── Delete.cshtml.cs │ │ ├── Edit.cshtml │ │ ├── Edit.cshtml.cs │ │ ├── Index.cshtml │ │ └── Index.cshtml.cs │ ├── ManageNavPages.cs │ ├── Notifications │ │ ├── Index.cshtml │ │ └── Index.cshtml.cs │ ├── Settings │ │ ├── Index.cshtml │ │ └── Index.cshtml.cs │ ├── Setup │ │ ├── Acme.cshtml │ │ ├── Acme.cshtml.cs │ │ ├── Certificate.cshtml │ │ ├── Certificate.cshtml.cs │ │ ├── Finished.cshtml │ │ ├── Finished.cshtml.cs │ │ ├── Index.cshtml │ │ ├── Index.cshtml.cs │ │ ├── Server.cshtml │ │ └── Server.cshtml.cs │ ├── Shared │ │ ├── _Layout.cshtml │ │ ├── _LoginPartial.cshtml │ │ ├── _SmtpOptionsMissing.cshtml │ │ └── _StatusMessage.cshtml │ ├── Tracking │ │ ├── Delete.cshtml │ │ ├── Delete.cshtml.cs │ │ ├── Edit.cshtml │ │ ├── Edit.cshtml.cs │ │ ├── History.cshtml │ │ ├── History.cshtml.cs │ │ ├── Index.cshtml │ │ ├── Index.cshtml.cs │ │ ├── Scan.cshtml │ │ └── Scan.cshtml.cs │ ├── _Layout.cshtml │ ├── _ManageNav.cshtml │ ├── _ViewImports.cshtml │ └── _ViewStart.cshtml ├── Program.cs ├── Properties │ ├── PublishProfiles │ │ └── template.pubxml │ └── launchSettings.json ├── Services │ ├── BackgroundTaskQueue.cs │ ├── CertificateAcquirer.cs │ ├── Dns │ │ ├── DnsVerifier.cs │ │ ├── LookupClientProvider.cs │ │ └── LookupClientWrapper.cs │ ├── DomainScanService.cs │ ├── DomainScanner.cs │ ├── HostedServices │ │ ├── CertificateAcquiryService.cs │ │ ├── CertificateChangeNotificationService.cs │ │ ├── CertificateExpirationNotificationService.cs │ │ ├── DomainScanIntervalService.cs │ │ └── QueuedHostedService.cs │ ├── KeyGenerator.cs │ ├── NotificationService.cs │ └── ProcessRunner.cs ├── Startup.cs ├── appsettings.Development.json ├── appsettings.json ├── bundleconfig.json ├── config.json └── wwwroot │ ├── css │ ├── fonts │ │ ├── icomoon.eot │ │ ├── icomoon.svg │ │ ├── icomoon.ttf │ │ └── icomoon.woff │ ├── icons.css │ ├── main.css │ ├── milligram.css │ ├── normalize.css │ └── site.min.css │ ├── favicon.ico │ ├── img │ └── logo.svg │ ├── js │ └── qrcode.min.js │ └── robots.txt └── Certera.sln /LICENSE.md: -------------------------------------------------------------------------------- 1 | License terms are outlined on the certera docs site: https://docs.certera.io/#license 2 | 3 | Full license details can be found here: https://docs.certera.io/license.html 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Certera 2 | A central validation server for Let's Encrypt certificates. 3 | 4 | A self-hosted, web application to durably store keys and certificates, issue and renew Let's Encrypt certificates and monitor certificate expiration. 5 | 6 | For more information, please read the docs: https://docs.certera.io 7 | -------------------------------------------------------------------------------- /build/build-all.ps1: -------------------------------------------------------------------------------- 1 | param ( 2 | [Parameter(Mandatory=$true)] 3 | [ValidatePattern("^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$")] 4 | [string] 5 | $Version 6 | ) 7 | 8 | ./build.ps1 $Version "win-x64" 9 | ./build.ps1 $Version "win-x86" 10 | ./build.ps1 $Version "linux-x64" 11 | ./build.ps1 $Version "linux-arm" 12 | -------------------------------------------------------------------------------- /build/build.ps1: -------------------------------------------------------------------------------- 1 | param ( 2 | [Parameter(Mandatory=$true)] 3 | [ValidatePattern("^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$")] 4 | [string] 5 | $Version, 6 | 7 | [ValidateSet("win-x64","win-x86","win-arm","osx-x64","linux-x64","linux-arm")] 8 | [string] 9 | $Runtime 10 | ) 11 | 12 | $PSScriptFilePath = Get-Item $MyInvocation.MyCommand.Path 13 | $RepoRoot = $PSScriptFilePath.Directory.Parent.FullName 14 | $SolutionPath = Join-Path -Path $RepoRoot -ChildPath "src\Certera.sln" 15 | $ProjectPath = Join-Path -Path $RepoRoot "src\Certera.Web\Certera.Web.csproj" 16 | $PublishOutput = Join-Path -Path $RepoRoot "src\publish\$Runtime" 17 | $Profile = Join-Path -Path $RepoRoot "src\Certera.Web\Properties\PublishProfiles\template.pubxml" 18 | 19 | $CleanVersion = $Version 20 | $VersionSuffix = "" 21 | if ($Version.Contains('-')) { 22 | $CleanVersion = $Version.Substring(0, $Version.IndexOf('-')) 23 | $VersionSuffix = $Version.Substring($Version.IndexOf('-')) 24 | } 25 | 26 | # Set the version in the csproj 27 | $xml = [Xml] (Get-Content $ProjectPath) 28 | $xml.Project.PropertyGroup.Version = $CleanVersion 29 | $xml.Project.PropertyGroup.AssemblyVersion = $CleanVersion 30 | $xml.Project.PropertyGroup.FileVersion = $CleanVersion 31 | $xml.Save($ProjectPath); 32 | 33 | Write-Output "PublishOutput: $PublishOutput" 34 | Write-Output "Profile: $Profile" 35 | 36 | # Go get nuget.exe if we don't have it 37 | $NuGet = "nuget.exe" 38 | $FileExists = Test-Path $NuGet 39 | If ($FileExists -eq $False) { 40 | $SourceNugetExe = "https://dist.nuget.org/win-x86-commandline/latest/nuget.exe" 41 | Invoke-WebRequest $SourceNugetExe -OutFile $NuGet 42 | } 43 | 44 | # Restore NuGet packages 45 | dotnet restore $SolutionPath 46 | 47 | # TODO: use PS Core and $IsLinux, $IsWindows, etc. 48 | $ReadyToRun = "false" 49 | if ($Runtime.StartsWith("win")) { 50 | $ReadyToRun = "true" 51 | } 52 | 53 | dotnet publish $ProjectPath -c Release -o "$PublishOutput" /p:PublishProfile="$Profile" /p:Version=$Version /p:AssemblyVersion=$CleanVersion /p:FileVersion=$CleanVersion /p:ReadyToRun=$ReadyToRun /p:RuntimeIdentifier=$Runtime 54 | 55 | ./zip.ps1 $RepoRoot "$PublishOutput" $RunTime $Version -------------------------------------------------------------------------------- /build/nuget.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/certera-io/certera/578dc7a1a4d1da4094c3b0a97c644ef928fe1fc2/build/nuget.exe -------------------------------------------------------------------------------- /build/zip.ps1: -------------------------------------------------------------------------------- 1 | param ( 2 | [Parameter(Mandatory=$true)] 3 | [string] 4 | $Root, 5 | 6 | [Parameter(Mandatory=$true)] 7 | [string] 8 | $PublishOutput, 9 | 10 | [Parameter(Mandatory=$true)] 11 | [string] 12 | $Runtime, 13 | 14 | [Parameter(Mandatory=$true)] 15 | [string] 16 | $Version 17 | ) 18 | 19 | Add-Type -Assembly "system.io.compression.filesystem" 20 | $Temp = "$Root\build\temp\" 21 | $Artifacts = "$Root\build\artifacts\" 22 | 23 | # Clean temp folder 24 | if (Test-Path $Temp) 25 | { 26 | Remove-Item $Temp -Recurse 27 | } 28 | New-Item $Temp -Type Directory 29 | 30 | if (!(Test-Path $Artifacts)) 31 | { 32 | New-Item $Artifacts -Type Directory 33 | } 34 | 35 | $ZipFile = "$Artifacts\certera-$Version-$Runtime.zip" 36 | if (Test-Path $ZipFile) 37 | { 38 | Remove-Item $ZipFile 39 | } 40 | 41 | Copy-Item -Path "$PublishOutput\*" -Destination "$Temp" -recurse -Force -Verbose 42 | 43 | Set-Content -Path "$Temp\version.txt" -Value "v$Version" 44 | 45 | [io.compression.zipfile]::CreateFromDirectory($Temp, $ZipFile) 46 | -------------------------------------------------------------------------------- /src/Certera.Core/Certera.Core.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.1 5 | embedded 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/Certera.Core/Concurrency/NamedLocker.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.CompilerServices; 3 | 4 | namespace Certera.Core.Concurrency 5 | { 6 | public static class NamedLocker 7 | { 8 | private static readonly ConditionalWeakTable _table = new ConditionalWeakTable(); 9 | 10 | public static object GetLock(string key) => _table.GetOrCreateValue(key); 11 | 12 | public static T RunWithLock(string key, Func func) 13 | { 14 | lock (GetLock(key)) 15 | { 16 | return func(); 17 | } 18 | } 19 | 20 | public static void RunWithLock(string key, Action a) 21 | { 22 | lock(GetLock(key)) 23 | { 24 | a(); 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Certera.Core/Extensions/DateTimeExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Certera.Core.Extensions 4 | { 5 | public static class DateTimeExtensions 6 | { 7 | public static string ToFriendlyString(this DateTime d) 8 | { 9 | // 1. 10 | // Get time span elapsed since the date. 11 | TimeSpan s = DateTime.Now.Subtract(d); 12 | 13 | // 2. 14 | // Get total number of days elapsed. 15 | int dayDiff = (int)s.TotalDays; 16 | 17 | // 3. 18 | // Get total number of seconds elapsed. 19 | int secDiff = (int)s.TotalSeconds; 20 | 21 | // 4. 22 | // Don't allow out of range values. 23 | if (dayDiff < 0 || dayDiff >= 31) 24 | { 25 | return null; 26 | } 27 | 28 | // 5. 29 | // Handle same-day times. 30 | if (dayDiff == 0) 31 | { 32 | // A. 33 | // Less than one minute ago. 34 | if (secDiff < 60) 35 | { 36 | return "just now"; 37 | } 38 | // B. 39 | // Less than 2 minutes ago. 40 | if (secDiff < 120) 41 | { 42 | return "1m ago"; 43 | } 44 | // C. 45 | // Less than one hour ago. 46 | if (secDiff < 3600) 47 | { 48 | return string.Format("{0}m ago", 49 | Math.Floor((double)secDiff / 60)); 50 | } 51 | // D. 52 | // Less than 2 hours ago. 53 | if (secDiff < 7200) 54 | { 55 | return "1h ago"; 56 | } 57 | // E. 58 | // Less than one day ago. 59 | if (secDiff < 86400) 60 | { 61 | return string.Format("{0}h ago", 62 | Math.Floor((double)secDiff / 3600)); 63 | } 64 | } 65 | // 6. 66 | // Handle previous days. 67 | if (dayDiff == 1) 68 | { 69 | return "1d ago"; 70 | } 71 | if (dayDiff < 31) 72 | { 73 | return string.Format("{0}d ago", dayDiff); 74 | } 75 | if (dayDiff < 365) 76 | { 77 | int months = (int)Math.Floor((double)dayDiff / 30); 78 | return months <= 1 ? "1mth ago" : months + "mths ago"; 79 | } 80 | int years = (int)Math.Floor((double)dayDiff / 365); 81 | return years <= 1 ? "1yr ago" : years + "yrs ago"; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Certera.Core/Extensions/LinqExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | 4 | namespace Certera.Core.Extensions 5 | { 6 | public static class LinqExtensions 7 | { 8 | public static bool IsNullOrEmpty(this IEnumerable enumerable) 9 | { 10 | return enumerable == null || !enumerable.Any(); 11 | } 12 | 13 | public static IEnumerable> Batch( 14 | this IEnumerable source, int size) 15 | { 16 | TSource[] bucket = null; 17 | var count = 0; 18 | 19 | foreach (var item in source) 20 | { 21 | if (bucket == null) 22 | bucket = new TSource[size]; 23 | 24 | bucket[count++] = item; 25 | if (count != size) 26 | { 27 | continue; 28 | } 29 | 30 | yield return bucket; 31 | 32 | bucket = null; 33 | count = 0; 34 | } 35 | 36 | if (bucket != null && count > 0) 37 | { 38 | yield return bucket.Take(count); 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Certera.Core/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Certera.Core.Extensions 2 | { 3 | public static class StringExtensions 4 | { 5 | public static bool IsNullOrWhiteSpace(this string str) 6 | { 7 | return string.IsNullOrWhiteSpace(str); 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Certera.Core/Helpers/DomainParser.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Nager.PublicSuffix; 3 | 4 | namespace Certera.Core.Helpers 5 | { 6 | public static class DomainParser 7 | { 8 | private static WebTldRuleProvider _webTldRuleProvider; 9 | private static Nager.PublicSuffix.DomainParser _domainParser; 10 | 11 | static DomainParser() 12 | { 13 | _webTldRuleProvider = new WebTldRuleProvider( 14 | cacheProvider: new FileCacheProvider(cacheTimeToLive: TimeSpan.FromDays(7))); 15 | _domainParser = new Nager.PublicSuffix.DomainParser(_webTldRuleProvider); 16 | } 17 | 18 | public static string RegistrableDomain(string host) 19 | { 20 | if (host == null) 21 | { 22 | return null; 23 | } 24 | 25 | if (host.Contains("*.")) 26 | { 27 | host = host.Replace("*.", ""); 28 | } 29 | 30 | if (_domainParser.IsValidDomain(host)) 31 | { 32 | var domain = _domainParser.Parse(host); 33 | return domain.RegistrableDomain; 34 | } 35 | 36 | return null; 37 | } 38 | 39 | public static string GetTld(string fullDomain) 40 | { 41 | return _domainParser.Parse(fullDomain).TLD; 42 | } 43 | 44 | public static string Subdomain(string fullDomain) 45 | { 46 | return _domainParser.Parse(fullDomain).SubDomain; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Certera.Core/Helpers/EnvironmentVariableHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Certera.Core.Helpers 6 | { 7 | public static class EnvironmentVariableHelper 8 | { 9 | public static IDictionary ToKeyValuePair(string envVars) 10 | { 11 | var result = new Dictionary(); 12 | if (!string.IsNullOrWhiteSpace(envVars)) 13 | { 14 | var lines = envVars.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); 15 | foreach (var line in lines) 16 | { 17 | var parts = line.Split("=", 2); 18 | if (parts.Length > 0) 19 | { 20 | var envKey = parts[0]; 21 | string value = null; 22 | if (parts.Length >= 1) 23 | { 24 | value = parts[1]; 25 | } 26 | 27 | result.Add(envKey, value); 28 | } 29 | } 30 | } 31 | return result; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Certera.Core/Notifications/MailSender.cs: -------------------------------------------------------------------------------- 1 | using MailKit.Net.Smtp; 2 | using MimeKit; 3 | using System; 4 | using System.Linq; 5 | 6 | namespace Certera.Core.Notifications 7 | { 8 | public class MailSender : IDisposable 9 | { 10 | private MailSenderInfo _info; 11 | private SmtpClient _client; 12 | private bool _initialized = false; 13 | 14 | public void Initialize(MailSenderInfo info) 15 | { 16 | if (_initialized) 17 | { 18 | return; 19 | } 20 | _initialized = true; 21 | _info = info; 22 | _client = new SmtpClient(); 23 | } 24 | 25 | public void Send(string subject, string body, params string[] recipients) 26 | { 27 | var message = new MimeMessage(); 28 | message.From.Add(new MailboxAddress(_info.FromName, _info.FromEmail)); 29 | message.To.AddRange(recipients.Select(x => MailboxAddress.Parse(x))); 30 | message.Subject = subject; 31 | message.Body = new TextPart(MimeKit.Text.TextFormat.Html) 32 | { 33 | Text = body 34 | }; 35 | 36 | EnsureConnected(); 37 | 38 | _client.Send(message); 39 | } 40 | 41 | private void EnsureConnected() 42 | { 43 | if (!_client.IsConnected) 44 | { 45 | _client.Connect(_info.Host, _info.Port, _info.UseSsl); 46 | 47 | if (_info.Username != null || _info.Password != null) 48 | { 49 | _client.Authenticate(_info.Username, _info.Password); 50 | } 51 | } 52 | } 53 | 54 | #region IDisposable Support 55 | private bool disposedValue = false; // To detect redundant calls 56 | 57 | protected virtual void Dispose(bool disposing) 58 | { 59 | if (!disposedValue) 60 | { 61 | if (disposing) 62 | { 63 | _client?.Dispose(); 64 | } 65 | 66 | disposedValue = true; 67 | } 68 | } 69 | 70 | public void Dispose() 71 | { 72 | Dispose(true); 73 | } 74 | #endregion 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Certera.Core/Notifications/MailSenderInfo.cs: -------------------------------------------------------------------------------- 1 | namespace Certera.Core.Notifications 2 | { 3 | public class MailSenderInfo 4 | { 5 | public string Host { get; set; } 6 | public int Port { get; set; } 7 | public string Username { get; set; } 8 | public string Password { get; set; } 9 | public bool UseSsl { get; set; } 10 | public string FromEmail { get; set; } 11 | public string FromName { get; set; } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Certera.Core/Notifications/Templates/NotificationCertificateAcquisitionFailureEmail.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 9 | 10 | 11 |
There was an error attempting to acquire a certificate for {{Domain}}.
12 | 
13 | Error
14 | {{Error}}
15 | 
16 | Last acquired 
17 | {{LastAcquiryText}}
18 | 
19 | {{PreviousCertificateDetails}}
20 |     
21 | 22 | -------------------------------------------------------------------------------- /src/Certera.Core/Notifications/Templates/NotificationCertificateAcquisitionFailureSlack.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "text": { 6 | "type": "mrkdwn", 7 | "text": "There was an error attempting to acquire a certificate for *{{Domain}}*." 8 | } 9 | }, 10 | { 11 | "type": "section", 12 | "text": { 13 | 14 | "type": "mrkdwn", 15 | "text": "*Error:*\n{{Error}}\n\n*Last acquired:*\n{{LastAcquiryText}}\n\n{{PreviousCertificateDetails}}" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/Certera.Core/Notifications/Templates/NotificationCertificateChangeEmail.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 9 | 10 | 11 |
Change detected in the {{Domain}} certificate.
12 | 
13 | New certificate details
14 | 
15 | Thumbprint
16 | {{NewThumbprint}}
17 | 
18 | Public Key (hash)
19 | {{NewPublicKey}}
20 | 
21 | Valid
22 | {{NewValidFrom}} to {{NewValidTo}}
23 | 
24 | Previous certificate details
25 | 
26 | Thumbprint
27 | {{PreviousThumbprint}}
28 | 
29 | Public Key (hash)
30 | {{PreviousPublicKey}}
31 | 
32 | Valid
33 | {{PreviousValidFrom}} to {{PreviousValidTo}}
34 |     
35 | 36 | 37 | -------------------------------------------------------------------------------- /src/Certera.Core/Notifications/Templates/NotificationCertificateChangeSlack.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "text": { 6 | "type": "mrkdwn", 7 | "text": "Change detected in the *{{Domain}}* certificate." 8 | } 9 | }, 10 | { 11 | "type": "section", 12 | "text": { 13 | 14 | "type": "mrkdwn", 15 | "text": "*New certificate details*\n*Thumbprint:*\n{{NewThumbprint}}\n*Public Key (hash):*\n{{NewPublicKey}}\n*Valid:*\n{{NewValidFrom}} to {{NewValidTo}}\n\n*Previous certificate details*\n*Thumbprint:*\n{{PreviousThumbprint}}\n*Public Key (hash):*\n{{PreviousPublicKey}}\n*Valid:*\n{{PreviousValidFrom}} to {{PreviousValidTo}}" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/Certera.Core/Notifications/Templates/NotificationCertificateExpirationEmail.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 9 | 10 | 11 |
{{Domain}} certificate is set to expire on {{DateTime}} ({{DaysText}}).
12 | 
13 | Current details
14 | 
15 | Thumbprint
16 | {{Thumbprint}}
17 | 
18 | Public Key (hash)
19 | {{PublicKey}}
20 | 
21 | Valid
22 | {{ValidFrom}} to {{ValidTo}}
23 |     
24 | 25 | 26 | -------------------------------------------------------------------------------- /src/Certera.Core/Notifications/Templates/NotificationCertificateExpirationSlack.json: -------------------------------------------------------------------------------- 1 | { 2 | "blocks": [ 3 | { 4 | "type": "section", 5 | "text": { 6 | "type": "mrkdwn", 7 | "text": "*{{Domain}}* certificate is set to expire on {{DateTime}} ({{DaysText}})." 8 | } 9 | }, 10 | { 11 | "type": "section", 12 | "text": { 13 | 14 | "type": "mrkdwn", 15 | "text": "*Current details*\n*Thumbprint:*\n{{Thumbprint}}\n*Public Key (hash):*\n{{PublicKey}}\n*Valid:*\n{{ValidFrom}} to {{ValidTo}}" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/Certera.Data/ApiKeyGenerator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Numerics; 3 | using System.Security.Cryptography; 4 | using System.Text; 5 | 6 | namespace Certera.Data 7 | { 8 | public static class ApiKeyGenerator 9 | { 10 | public static string CreateApiKey() 11 | { 12 | var bytes = new byte[256 / 8]; 13 | using (var random = RandomNumberGenerator.Create()) 14 | random.GetBytes(bytes); 15 | return ToBase62String(bytes); 16 | } 17 | 18 | private static string ToBase62String(byte[] toConvert) 19 | { 20 | const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; 21 | BigInteger dividend = new BigInteger(toConvert); 22 | var builder = new StringBuilder(); 23 | while (dividend != 0) 24 | { 25 | dividend = BigInteger.DivRem(dividend, alphabet.Length, out BigInteger remainder); 26 | builder.Insert(0, alphabet[Math.Abs(((int)remainder))]); 27 | } 28 | return builder.ToString(); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Certera.Data/ApplicationUser.partial.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data.Models; 2 | using Microsoft.AspNetCore.Identity; 3 | using System.Collections.Generic; 4 | 5 | namespace Certera.Data 6 | { 7 | public partial class ApplicationUser : IdentityUser 8 | { 9 | public NotificationSetting NotificationSetting { get; set; } 10 | 11 | public virtual ICollection UserConfigurations { get; set; } = new List(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Certera.Data/Certera.Data.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.1 5 | embedded 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | 19 | all 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Certera.Data/DataContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Identity; 2 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore; 4 | using System.ComponentModel; 5 | 6 | namespace Certera.Data 7 | { 8 | public partial class DataContext : 9 | IdentityDbContext 10 | { 11 | public DataContext() 12 | { 13 | } 14 | 15 | public DataContext(DbContextOptions options) 16 | : base(options) 17 | { 18 | } 19 | 20 | static DataContext() 21 | { 22 | // required to initialise native SQLite libraries on some platforms. 23 | SQLitePCL.Batteries_V2.Init(); 24 | 25 | // https://github.com/aspnet/EntityFrameworkCore/issues/9994#issuecomment-508588678 26 | SQLitePCL.raw.sqlite3_config(2 /*SQLITE_CONFIG_MULTITHREAD*/); 27 | } 28 | 29 | protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 30 | { 31 | if (!optionsBuilder.IsConfigured) 32 | { 33 | optionsBuilder.UseSqlite("Data Source=Certera.db"); 34 | } 35 | } 36 | 37 | protected override void OnModelCreating(ModelBuilder builder) 38 | { 39 | builder.Entity() 40 | .HasIndex(x => x.ApiKey1) 41 | .IsUnique(); 42 | builder.Entity() 43 | .HasIndex(x => x.ApiKey2) 44 | .IsUnique(); 45 | 46 | ConfigureDataModels(builder); 47 | 48 | base.OnModelCreating(builder); 49 | } 50 | } 51 | 52 | public partial class ApplicationUser : IdentityUser 53 | { 54 | [DisplayName("API Key 1")] 55 | public string ApiKey1 { get; set; } 56 | 57 | [DisplayName("API Key 2")] 58 | public string ApiKey2 { get; set; } 59 | } 60 | 61 | public class UserLogin : IdentityUserLogin { } 62 | public class UserRole : IdentityUserRole { } 63 | public class UserClaim : IdentityUserClaim { } 64 | 65 | public class Role : IdentityRole 66 | { 67 | public Role() { } 68 | public Role(string role) : base(role) { } 69 | } 70 | 71 | public class RoleClaim : IdentityRoleClaim { } 72 | public class UserToken : IdentityUserToken { } 73 | } 74 | -------------------------------------------------------------------------------- /src/Certera.Data/Migrations/20200107001128_IndividualApiKey.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | namespace Certera.Data.Migrations 4 | { 5 | public partial class IndividualApiKey : Migration 6 | { 7 | protected override void Up(MigrationBuilder migrationBuilder) 8 | { 9 | migrationBuilder.AddColumn( 10 | name: "ApiKey1", 11 | table: "Keys", 12 | nullable: true); 13 | 14 | migrationBuilder.AddColumn( 15 | name: "ApiKey2", 16 | table: "Keys", 17 | nullable: true); 18 | 19 | migrationBuilder.AddColumn( 20 | name: "ApiKey1", 21 | table: "AcmeCertificates", 22 | nullable: true); 23 | 24 | migrationBuilder.AddColumn( 25 | name: "ApiKey2", 26 | table: "AcmeCertificates", 27 | nullable: true); 28 | 29 | migrationBuilder.CreateIndex( 30 | name: "IX_Keys_ApiKey1", 31 | table: "Keys", 32 | column: "ApiKey1", 33 | unique: true); 34 | 35 | migrationBuilder.CreateIndex( 36 | name: "IX_Keys_ApiKey2", 37 | table: "Keys", 38 | column: "ApiKey2", 39 | unique: true); 40 | 41 | migrationBuilder.CreateIndex( 42 | name: "IX_AcmeCertificates_ApiKey1", 43 | table: "AcmeCertificates", 44 | column: "ApiKey1", 45 | unique: true); 46 | 47 | migrationBuilder.CreateIndex( 48 | name: "IX_AcmeCertificates_ApiKey2", 49 | table: "AcmeCertificates", 50 | column: "ApiKey2", 51 | unique: true); 52 | } 53 | 54 | protected override void Down(MigrationBuilder migrationBuilder) 55 | { 56 | migrationBuilder.DropIndex( 57 | name: "IX_Keys_ApiKey1", 58 | table: "Keys"); 59 | 60 | migrationBuilder.DropIndex( 61 | name: "IX_Keys_ApiKey2", 62 | table: "Keys"); 63 | 64 | migrationBuilder.DropIndex( 65 | name: "IX_AcmeCertificates_ApiKey1", 66 | table: "AcmeCertificates"); 67 | 68 | migrationBuilder.DropIndex( 69 | name: "IX_AcmeCertificates_ApiKey2", 70 | table: "AcmeCertificates"); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Certera.Data/Migrations/20200108175150_SetApiKeyValues.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | using System.Linq; 3 | 4 | namespace Certera.Data.Migrations 5 | { 6 | public partial class SetApiKeyValues : Migration 7 | { 8 | protected override void Up(MigrationBuilder migrationBuilder) 9 | { 10 | using (var ctx = new DataContext()) 11 | { 12 | var certs = ctx.AcmeCertificates.Where(x => x.ApiKey1 == null).ToList(); 13 | foreach (var cert in certs) 14 | { 15 | cert.ApiKey1 = ApiKeyGenerator.CreateApiKey(); 16 | cert.ApiKey2 = ApiKeyGenerator.CreateApiKey(); 17 | } 18 | 19 | var keys = ctx.Keys.Where(x => x.ApiKey1 == null).ToList(); 20 | foreach (var key in keys) 21 | { 22 | key.ApiKey1 = ApiKeyGenerator.CreateApiKey(); 23 | key.ApiKey2 = ApiKeyGenerator.CreateApiKey(); 24 | } 25 | ctx.SaveChanges(); 26 | } 27 | } 28 | 29 | protected override void Down(MigrationBuilder migrationBuilder) 30 | { 31 | 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Certera.Data/Migrations/20200303222607_SlackNotification.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | namespace Certera.Data.Migrations 4 | { 5 | public partial class SlackNotification : Migration 6 | { 7 | protected override void Up(MigrationBuilder migrationBuilder) 8 | { 9 | migrationBuilder.AddColumn( 10 | name: "SendEmailNotification", 11 | table: "NotificationSettings", 12 | nullable: false, 13 | defaultValue: true); 14 | 15 | migrationBuilder.AddColumn( 16 | name: "SendSlackNotification", 17 | table: "NotificationSettings", 18 | nullable: false, 19 | defaultValue: false); 20 | 21 | migrationBuilder.AddColumn( 22 | name: "SlackWebhookUrl", 23 | table: "NotificationSettings", 24 | nullable: true); 25 | } 26 | 27 | protected override void Down(MigrationBuilder migrationBuilder) 28 | { 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Certera.Data/Models/AcmeAccount.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.ComponentModel.DataAnnotations; 5 | 6 | namespace Certera.Data.Models 7 | { 8 | public class AcmeAccount 9 | { 10 | public long AcmeAccountId { get; set; } 11 | 12 | [Required] 13 | [EmailAddress] 14 | [DisplayName("ACME Contact Email")] 15 | public string AcmeContactEmail { get; set; } 16 | 17 | [DisplayName("Accept Let's Encrypt Terms of Service")] 18 | [Range(typeof(bool), "true", "true", ErrorMessage="You must accept the terms of service")] 19 | public bool AcmeAcceptTos { get; set; } 20 | 21 | [Display(Name = "Created")] 22 | public DateTime DateCreated { get; set; } 23 | 24 | [Display(Name = "Modified")] 25 | public DateTime DateModified { get; set; } 26 | 27 | /// 28 | /// Indicates whether this is associated with the ACME staging site 29 | /// 30 | public bool IsAcmeStaging { get; set; } 31 | 32 | public long KeyId { get; set; } 33 | public Key Key { get; set; } 34 | 35 | public long ApplicationUserId { get; set; } 36 | 37 | [DisplayName("User")] 38 | public ApplicationUser ApplicationUser { get; set; } 39 | 40 | public virtual ICollection AcmeCertificates { get; set; } = new List(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Certera.Data/Models/AcmeCertificate.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Internal; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.ComponentModel; 5 | using System.ComponentModel.DataAnnotations; 6 | using System.ComponentModel.DataAnnotations.Schema; 7 | using System.Linq; 8 | 9 | namespace Certera.Data.Models 10 | { 11 | public class AcmeCertificate 12 | { 13 | [Key] 14 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 15 | public long AcmeCertificateId { get; set; } 16 | 17 | [Required] 18 | [RegularExpression("^[a-zA-Z0-9-_.]+$", 19 | ErrorMessage = "Only alpha-numeric and the following characters: . - _ (no whitespace allowed)")] 20 | public string Name { get; set; } 21 | 22 | [Display(Name = "Created")] 23 | public DateTime DateCreated { get; set; } 24 | 25 | [Display(Name = "Modified")] 26 | public DateTime DateModified { get; set; } 27 | 28 | [Required] 29 | public string Subject { get; set; } 30 | 31 | [Display(Name = "Subject Alternative Names")] 32 | public string SANs { get; set; } 33 | 34 | public long KeyId { get; set; } 35 | 36 | public Key Key { get; set; } 37 | 38 | [Display(Name = "ACME Challenge Type")] 39 | [RegularExpression("http-01|dns-01")] 40 | public string ChallengeType { get; set; } 41 | 42 | [Display(Name = "Country (C)")] 43 | public string CsrCountryName { get; set; } 44 | 45 | [Display(Name = "State (S)")] 46 | public string CsrState { get; set; } 47 | 48 | [Display(Name = "City (L)")] 49 | public string CsrLocality { get; set; } 50 | 51 | [Display(Name = "Organization (O)")] 52 | public string CsrOrganization { get; set; } 53 | 54 | [Display(Name = "Organization Unit (OU)")] 55 | public string CsrOrganizationUnit { get; set; } 56 | 57 | [Display(Name = "Common Name (CN)")] 58 | public string CsrCommonName { get; set; } 59 | 60 | public long AcmeAccountId { get; set; } 61 | 62 | public AcmeAccount AcmeAccount { get; set; } 63 | 64 | public virtual ICollection AcmeOrders { get; set; } = new List(); 65 | 66 | [DisplayName("API Key 1")] 67 | public string ApiKey1 { get; set; } 68 | 69 | [DisplayName("API Key 2")] 70 | public string ApiKey2 { get; set; } 71 | 72 | [NotMapped] 73 | public AcmeOrder LatestValidAcmeOrder { get; set; } 74 | 75 | public AcmeOrder GetLatestValidAcmeOrder() 76 | { 77 | return AcmeOrders.Where(x => x.Status == AcmeOrderStatus.Completed).OrderByDescending(x => x.DateCreated).FirstOrDefault(); 78 | } 79 | 80 | public bool IsDnsChallengeType() 81 | { 82 | return string.Equals(ChallengeType, "dns-01", StringComparison.OrdinalIgnoreCase); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Certera.Data/Models/AcmeOrder.cs: -------------------------------------------------------------------------------- 1 | using Certes.Acme; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Security.Cryptography.X509Certificates; 5 | 6 | namespace Certera.Data.Models 7 | { 8 | public class AcmeOrder 9 | { 10 | public long AcmeOrderId { get; set; } 11 | public DateTime DateCreated { get; set; } 12 | public int RequestCount { get; set; } 13 | public int InvalidResponseCount { get; set; } 14 | public AcmeOrderStatus Status { get; set; } 15 | public string Errors { get; set; } 16 | public string RawDataPem { get; set; } 17 | 18 | public long AcmeCertificateId { get; set; } 19 | public AcmeCertificate AcmeCertificate { get; set; } 20 | 21 | public long? DomainCertificateId { get; set; } 22 | public DomainCertificate DomainCertificate { get; set; } 23 | 24 | public virtual ICollection AcmeRequests { get; set; } = new List(); 25 | 26 | public X509Certificate2 Certificate 27 | { 28 | get 29 | { 30 | if (RawDataPem == null) 31 | { 32 | return null; 33 | } 34 | var chain = new CertificateChain(RawDataPem); 35 | var bytes = chain.Certificate.ToDer(); 36 | var cert = new X509Certificate2(bytes); 37 | 38 | return cert; 39 | } 40 | } 41 | 42 | public bool Completed 43 | { 44 | get 45 | { 46 | return Status == AcmeOrderStatus.Completed; 47 | } 48 | } 49 | } 50 | 51 | public enum AcmeOrderStatus 52 | { 53 | Created = 0, 54 | Challenging, 55 | Validating, 56 | Invalid, 57 | Error, 58 | Completed 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Certera.Data/Models/AcmeRequest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | 4 | namespace Certera.Data.Models 5 | { 6 | public class AcmeRequest 7 | { 8 | public long AcmeRequestId { get; set; } 9 | public DateTime DateCreated { get; set; } 10 | 11 | // The filename, e.g. abcdefg 12 | // for: /.well-known/acme-challenge/abcdefg 13 | public string Token { get; set; } 14 | 15 | // The contents of the challenge file 16 | // e.g. abcdefg.tuvwxyz 17 | public string KeyAuthorization { get; set; } 18 | 19 | [NotMapped] 20 | public string Domain { get; set; } 21 | 22 | [NotMapped] 23 | public string DnsTxtValue { get; set; } 24 | 25 | public long AcmeOrderId { get; set; } 26 | public AcmeOrder AcmeOrder { get; set; } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Certera.Data/Models/Domain.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel.DataAnnotations.Schema; 4 | using System.Linq; 5 | 6 | namespace Certera.Data.Models 7 | { 8 | public class Domain 9 | { 10 | public long DomainId { get; set; } 11 | public string Uri { get; set; } 12 | public DateTime DateCreated { get; set; } 13 | public DateTime? DateLastScanned { get; set; } 14 | public string RegistrableDomain { get; set; } 15 | public int Order { get; set; } 16 | 17 | public virtual ICollection DomainScans { get; set; } = new List(); 18 | 19 | [NotMapped] 20 | public DomainScan LatestDomainScan { get; set; } 21 | 22 | [NotMapped] 23 | public DomainScan LatestValidDomainScan { get; set; } 24 | 25 | public string HostAndPort() 26 | { 27 | try 28 | { 29 | return new Uri(Uri).Authority; 30 | } 31 | catch { } 32 | return Uri; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Certera.Data/Models/DomainCertificateChangeEvent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Certera.Data.Models 4 | { 5 | public class DomainCertificateChangeEvent 6 | { 7 | public long DomainCertificateChangeEventId { get; set; } 8 | 9 | public long NewDomainCertificateId { get; set; } 10 | public DomainCertificate NewDomainCertificate { get; set; } 11 | 12 | public long PreviousDomainCertificateId { get; set; } 13 | public DomainCertificate PreviousDomainCertificate { get; set; } 14 | 15 | public long DomainId { get; set; } 16 | public Domain Domain { get; set; } 17 | 18 | public DateTime DateCreated { get; set; } 19 | public DateTime? DateProcessed { get; set; } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Certera.Data/Models/DomainScan.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Certera.Data.Models 4 | { 5 | public class DomainScan 6 | { 7 | public long DomainScanId { get; set; } 8 | public DateTime DateScan { get; set; } 9 | public bool ScanSuccess { get; set; } 10 | public string ScanResult { get; set; } 11 | public string ScanStatus { get; set; } 12 | 13 | public long DomainId { get; set; } 14 | public Domain Domain { get; set; } 15 | 16 | public long? DomainCertificateId { get; set; } 17 | public DomainCertificate DomainCertificate { get; set; } 18 | 19 | public long? DomainCertificateChangeEventId { get; set; } 20 | public DomainCertificateChangeEvent DomainCertificateChangeEvent { get; set; } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Certera.Data/Models/Key.cs: -------------------------------------------------------------------------------- 1 | using Certes; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.ComponentModel; 5 | using System.ComponentModel.DataAnnotations; 6 | 7 | namespace Certera.Data.Models 8 | { 9 | public class Key 10 | { 11 | public long KeyId { get; set; } 12 | 13 | [Required] 14 | [RegularExpression("^[a-zA-Z0-9-_.]+$", 15 | ErrorMessage = "Only alpha-numeric and the following characters: . - _ (no whitespace allowed)")] 16 | public string Name { get; set; } 17 | 18 | public string Description { get; set; } 19 | 20 | /// 21 | /// PEM encoded. 22 | /// 23 | [Display(Name = "PEM Encoded Key")] 24 | public string RawData { get; set; } 25 | 26 | [Display(Name = "Created")] 27 | public DateTime DateCreated { get; set; } 28 | 29 | [Display(Name = "Modified")] 30 | public DateTime DateModified { get; set; } 31 | 32 | [Display(Name = "Rotation")] 33 | public DateTimeOffset? DateRotationReminder { get; set; } 34 | 35 | [DisplayName("API Key 1")] 36 | public string ApiKey1 { get; set; } 37 | 38 | [DisplayName("API Key 2")] 39 | public string ApiKey2 { get; set; } 40 | 41 | public virtual ICollection KeyHistories { get; set; } = new List(); 42 | public virtual ICollection AcmeAccounts { get; set; } = new List(); 43 | public virtual ICollection AcmeCertificates { get; set; } = new List(); 44 | 45 | private IKey _ikey; 46 | public IKey IKey 47 | { 48 | get 49 | { 50 | if (string.IsNullOrWhiteSpace(RawData)) 51 | { 52 | return null; 53 | } 54 | if (_ikey == null) 55 | { 56 | _ikey = KeyFactory.FromPem(RawData); 57 | } 58 | return _ikey; 59 | } 60 | } 61 | 62 | public string Algorithm 63 | { 64 | get 65 | { 66 | if (string.IsNullOrWhiteSpace(RawData)) 67 | { 68 | return null; 69 | } 70 | var alg = IKey.Algorithm; 71 | switch (alg) 72 | { 73 | case KeyAlgorithm.RS256: 74 | return "RSA-PKCS1-v1_5 (SHA-256)"; 75 | case KeyAlgorithm.ES256: 76 | return "ECDSA P-256 (SHA-256)"; 77 | case KeyAlgorithm.ES384: 78 | return "ECDSA P-384 (SHA-384)"; 79 | case KeyAlgorithm.ES512: 80 | return "ECDSA P-521 (SHA-512)"; 81 | default: 82 | return "Unknown"; 83 | } 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Certera.Data/Models/KeyHistory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Certera.Data.Models 4 | { 5 | public class KeyHistory 6 | { 7 | public long KeyHistoryId { get; set; } 8 | 9 | public long KeyId { get; set; } 10 | public Key Key { get; set; } 11 | 12 | public long? ApplicationUserId { get; set; } 13 | public ApplicationUser ApplicationUser { get; set; } 14 | 15 | public string Operation { get; set; } 16 | public DateTime DateOperation { get; set; } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Certera.Data/Models/NotificationSetting.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | 3 | namespace Certera.Data.Models 4 | { 5 | public class NotificationSetting 6 | { 7 | public long NotificationSettingId { get; set; } 8 | 9 | [DefaultValue(true)] 10 | [DisplayName("Expiration Alerts")] 11 | public bool ExpirationAlerts { get; set; } = true; 12 | 13 | [DefaultValue(true)] 14 | [DisplayName("Change Alerts")] 15 | public bool ChangeAlerts { get; set; } = true; 16 | 17 | [DefaultValue(true)] 18 | [DisplayName("Acquisition Failure Alerts")] 19 | public bool AcquisitionFailureAlerts { get; set; } = true; 20 | 21 | [DefaultValue(true)] 22 | [DisplayName("1 day before expiration")] 23 | public bool ExpirationAlert1Day { get; set; } = true; 24 | 25 | [DefaultValue(true)] 26 | [DisplayName("3 days before expiration")] 27 | public bool ExpirationAlert3Days { get; set; } = true; 28 | 29 | [DefaultValue(true)] 30 | [DisplayName("7 days before expiration")] 31 | public bool ExpirationAlert7Days { get; set; } = true; 32 | 33 | [DefaultValue(true)] 34 | [DisplayName("14 days before expiration")] 35 | public bool ExpirationAlert14Days { get; set; } = true; 36 | 37 | [DefaultValue(true)] 38 | [DisplayName("30 days before expiration")] 39 | public bool ExpirationAlert30Days { get; set; } = true; 40 | 41 | [DisplayName("Additional Recipients")] 42 | public string AdditionalRecipients { get; set; } 43 | 44 | [DefaultValue(true)] 45 | [DisplayName("Send email notification")] 46 | public bool SendEmailNotification { get; set; } = true; 47 | 48 | [DefaultValue(false)] 49 | [DisplayName("Send Slack notification")] 50 | public bool SendSlackNotification { get; set; } 51 | 52 | [DisplayName("Slack Webhook URL")] 53 | public string SlackWebhookUrl { get; set; } 54 | 55 | public long ApplicationUserId { get; set; } 56 | public ApplicationUser ApplicationUser { get; set; } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Certera.Data/Models/Setting.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Specialized; 4 | using System.ComponentModel.DataAnnotations; 5 | 6 | namespace Certera.Data.Models 7 | { 8 | public class Setting 9 | { 10 | public long SettingId { get; set; } 11 | 12 | [Required] 13 | public string Name { get; set; } 14 | 15 | public string Value { get; set; } 16 | } 17 | 18 | public class DnsSettingsContainer 19 | { 20 | public string DnsEnvironmentVariables { get; set; } 21 | public string DnsSetupScript { get; set; } 22 | public string DnsCleanupScript { get; set; } 23 | public string DnsSetupScriptArguments { get; set; } 24 | public string DnsCleanupScriptArguments { get; set; } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Certera.Data/Models/UserConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Certera.Data.Models 4 | { 5 | public class UserConfiguration 6 | { 7 | public long UserConfigurationId { get; set; } 8 | 9 | [Required] 10 | public string Name { get; set; } 11 | 12 | public string Value { get; set; } 13 | 14 | public long ApplicationUserId { get; set; } 15 | public virtual ApplicationUser ApplicationUser { get; set; } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Certera.Data/Models/UserNotification.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Certera.Data.Models 4 | { 5 | public class UserNotification 6 | { 7 | public long UserNotificationId { get; set; } 8 | public DateTime DateCreated { get; set; } 9 | 10 | public long ApplicationUserId { get; set; } 11 | public ApplicationUser ApplicationUser { get; set; } 12 | 13 | public long DomainCertificateId { get; set; } 14 | public DomainCertificate DomainCertificate { get; set; } 15 | 16 | public NotificationEvent NotificationEvent { get; set; } 17 | } 18 | 19 | public enum NotificationEvent 20 | { 21 | ExpirationAlert1Day, 22 | ExpirationAlert3Days, 23 | ExpirationAlert7Days, 24 | ExpirationAlert14Days, 25 | ExpirationAlert30Days 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Certera.Web/Authentication/ApiKeyAuthorizeAttribute.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | 3 | namespace Certera.Web.Authentication 4 | { 5 | public class CertApiKeyAuthorizeAttribute : AuthorizeAttribute 6 | { 7 | public CertApiKeyAuthorizeAttribute() 8 | { 9 | AuthenticationSchemes = CertApiKeyAuthenticationHandler.AuthScheme; 10 | } 11 | } 12 | 13 | public class KeyApiKeyAuthorizeAttribute : AuthorizeAttribute 14 | { 15 | public KeyApiKeyAuthorizeAttribute() 16 | { 17 | AuthenticationSchemes = KeyApiKeyAuthenticationHandler.AuthScheme; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Certera.Web/Authentication/CertApiKeyAuthenticationHandler.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data; 2 | using Microsoft.AspNetCore.Authentication; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Options; 6 | using System.Security.Claims; 7 | using System.Text.Encodings.Web; 8 | using System.Threading.Tasks; 9 | 10 | namespace Certera.Web.Authentication 11 | { 12 | public class CertApiKeyAuthenticationHandler : AuthenticationHandler 13 | { 14 | public const string AuthScheme = "CertApiKey"; 15 | private readonly DataContext _dataContext; 16 | 17 | public CertApiKeyAuthenticationHandler(IOptionsMonitor options, 18 | ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, DataContext dataContext) 19 | : base(options, logger, encoder, clock) 20 | { 21 | _dataContext = dataContext; 22 | } 23 | 24 | protected override async Task HandleAuthenticateAsync() 25 | { 26 | if (!Request.Headers.ContainsKey("apikey")) 27 | { 28 | return AuthenticateResult.Fail("Missing header: apikey"); 29 | } 30 | 31 | var apiKey = Request.Headers["apikey"][0]; 32 | var cert = await _dataContext.AcmeCertificates.FirstOrDefaultAsync(x => x.ApiKey1 == apiKey || x.ApiKey2 == apiKey); 33 | 34 | if (cert == null) 35 | { 36 | return AuthenticateResult.Fail("Invalid apikey"); 37 | } 38 | 39 | var identity = new ClaimsIdentity("CustomApiKey"); 40 | identity.AddClaim(new Claim(ClaimTypes.Name, "cert")); 41 | identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, cert.AcmeCertificateId.ToString())); 42 | 43 | var principal = new ClaimsPrincipal(identity); 44 | 45 | var ticket = new AuthenticationTicket(principal, AuthScheme); 46 | 47 | return AuthenticateResult.Success(ticket); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Certera.Web/Authentication/KeyApiKeyAuthenticationHandler.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data; 2 | using Microsoft.AspNetCore.Authentication; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Options; 6 | using System.Security.Claims; 7 | using System.Text.Encodings.Web; 8 | using System.Threading.Tasks; 9 | 10 | namespace Certera.Web.Authentication 11 | { 12 | public class KeyApiKeyAuthenticationHandler : AuthenticationHandler 13 | { 14 | public const string AuthScheme = "KeyApiKey"; 15 | private readonly DataContext _dataContext; 16 | 17 | public KeyApiKeyAuthenticationHandler(IOptionsMonitor options, 18 | ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, DataContext dataContext) 19 | : base(options, logger, encoder, clock) 20 | { 21 | _dataContext = dataContext; 22 | } 23 | 24 | protected override async Task HandleAuthenticateAsync() 25 | { 26 | if (!Request.Headers.ContainsKey("apikey")) 27 | { 28 | return AuthenticateResult.Fail("Missing header: apikey"); 29 | } 30 | 31 | var apiKey = Request.Headers["apikey"][0]; 32 | var key = await _dataContext.Keys.FirstOrDefaultAsync(x => x.ApiKey1 == apiKey || x.ApiKey2 == apiKey); 33 | 34 | if (key == null) 35 | { 36 | return AuthenticateResult.Fail("Invalid apikey"); 37 | } 38 | 39 | var identity = new ClaimsIdentity("CustomApiKey"); 40 | identity.AddClaim(new Claim(ClaimTypes.Name, "key")); 41 | identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, key.KeyId.ToString())); 42 | 43 | var principal = new ClaimsPrincipal(identity); 44 | 45 | var ticket = new AuthenticationTicket(principal, AuthScheme); 46 | 47 | return AuthenticateResult.Success(ticket); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Certera.Web/Certera.Web.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net5.0 4 | aspnet-Certera.Web-5A368144-3DE0-4512-9F6D-C55B403FAC22 5 | win-x64 6 | certera 7 | false 8 | codesigning.pfx 9 | false 10 | A central validation server for Let's Encrypt certificates. 11 | https://docs.certera.io/#license 12 | https://certera.io 13 | https://github.com/certera-io/certera 14 | 2.2.0 15 | 2.2.0 16 | 2.2.0 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | all 32 | runtime; build; native; contentfiles; analyzers; buildtransitive 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | all 42 | runtime; build; native; contentfiles; analyzers; buildtransitive 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/Certera.Web/Controllers/CertificateController.cs: -------------------------------------------------------------------------------- 1 | using Certes; 2 | using Certes.Acme; 3 | using Certera.Data; 4 | using Certera.Web.Authentication; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.EntityFrameworkCore; 7 | using System; 8 | using System.Threading.Tasks; 9 | using System.Security.Claims; 10 | 11 | namespace Certera.Web.Controllers 12 | { 13 | [CertApiKeyAuthorize] 14 | [Route("api/[controller]")] 15 | public class CertificateController : Controller 16 | { 17 | private readonly DataContext _dataContext; 18 | 19 | public CertificateController(DataContext dataContext) 20 | { 21 | _dataContext = dataContext; 22 | } 23 | 24 | [HttpGet("{name}")] 25 | public IActionResult Index(string name, string pfxPassword, bool staging = false, 26 | string format = "pem", bool chain = true) 27 | { 28 | if (string.Equals(format, "pfx", StringComparison.OrdinalIgnoreCase) && 29 | string.IsNullOrWhiteSpace(pfxPassword)) 30 | { 31 | return BadRequest("pfxPassword must be specified"); 32 | } 33 | 34 | var acmeCert = _dataContext.GetAcmeCertificate(name, staging); 35 | 36 | if (acmeCert == null) 37 | { 38 | return NotFound("Certificate with that name does not exist"); 39 | } 40 | 41 | if (acmeCert.LatestValidAcmeOrder == null) 42 | { 43 | return NotFound("Certificate does not yet exist"); 44 | } 45 | 46 | // Ensure cert matches the one used during authentication 47 | var id = User.FindFirst(x => x.Type == ClaimTypes.NameIdentifier)?.Value; 48 | if (!string.Equals(acmeCert.AcmeCertificateId.ToString(), id)) 49 | { 50 | return StatusCode(403, "Status Code: 403; Forbidden"); 51 | } 52 | 53 | switch (format?.ToLower() ?? "pem") 54 | { 55 | case "pfx": 56 | var certChain = new CertificateChain(acmeCert.LatestValidAcmeOrder.RawDataPem); 57 | var key = KeyFactory.FromPem(acmeCert.Key.RawData); 58 | var pfxBuilder = certChain.ToPfx(key); 59 | var pfx = pfxBuilder.Build(acmeCert.Subject, pfxPassword); 60 | 61 | return new ContentResult 62 | { 63 | Content = Convert.ToBase64String(pfx), 64 | ContentType = "text/plain", 65 | StatusCode = 200 66 | }; 67 | case "pem": 68 | default: 69 | var content = chain ? acmeCert.LatestValidAcmeOrder.RawDataPem : 70 | new CertificateChain(acmeCert.LatestValidAcmeOrder.RawDataPem).Certificate.ToPem(); 71 | return new ContentResult 72 | { 73 | Content = content, 74 | ContentType = "text/plain", 75 | StatusCode = 200 76 | }; 77 | } 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /src/Certera.Web/Controllers/KeyController.cs: -------------------------------------------------------------------------------- 1 | using Certes; 2 | using Certera.Data; 3 | using Certera.Web.Authentication; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.EntityFrameworkCore; 6 | using System; 7 | using System.Threading.Tasks; 8 | using System.Security.Claims; 9 | 10 | namespace Certera.Web.Controllers 11 | { 12 | [KeyApiKeyAuthorize] 13 | [Route("api/[controller]")] 14 | public class KeyController : Controller 15 | { 16 | private readonly DataContext _dataContext; 17 | 18 | public KeyController(DataContext dataContext) 19 | { 20 | _dataContext = dataContext; 21 | } 22 | 23 | [HttpGet("{name}")] 24 | public async Task Index(string name, 25 | string format = "pem") 26 | { 27 | var key = await _dataContext.Keys 28 | .FirstOrDefaultAsync(x => x.Name == name); 29 | 30 | if (key == null) 31 | { 32 | return NotFound("Key with that name does not exist"); 33 | } 34 | 35 | // Ensure key matches the one used during authentication 36 | var id = User.FindFirst(x => x.Type == ClaimTypes.NameIdentifier)?.Value; 37 | if (!string.Equals(key.KeyId.ToString(), id)) 38 | { 39 | return StatusCode(403, "Status Code: 403; Forbidden"); 40 | } 41 | 42 | switch (format?.ToLower() ?? "pem") 43 | { 44 | case "der": 45 | var ikey = KeyFactory.FromPem(key.RawData); 46 | return new ContentResult 47 | { 48 | Content = Convert.ToBase64String(ikey.ToDer()), 49 | ContentType = "text/plain", 50 | StatusCode = 200 51 | }; 52 | case "pem": 53 | default: 54 | return new ContentResult 55 | { 56 | Content = key.RawData, 57 | ContentType = "text/plain", 58 | StatusCode = 200 59 | }; 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /src/Certera.Web/Controllers/TestController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace Certera.Web.Controllers 4 | { 5 | [Route("api/[controller]")] 6 | public class TestController : Controller 7 | { 8 | [HttpGet] 9 | public IActionResult Get() 10 | { 11 | return Ok(); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Certera.Web/Controllers/WellKnownController.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data; 2 | using Microsoft.AspNetCore.Mvc; 3 | using System; 4 | using System.Linq; 5 | 6 | namespace Certera.Web.Controllers 7 | { 8 | public class WellKnownController : Controller 9 | { 10 | private readonly DataContext _dataContext; 11 | 12 | public WellKnownController(DataContext dataContext) 13 | { 14 | _dataContext = dataContext; 15 | } 16 | 17 | [HttpGet(".well-known/acme-challenge/{id}")] 18 | public IActionResult AcmeChallenge(string id) 19 | { 20 | var acmeReq = _dataContext.AcmeRequests.FirstOrDefault(x => x.Token == id); 21 | if (acmeReq == null) 22 | { 23 | return NotFound(); 24 | } 25 | 26 | return new ContentResult 27 | { 28 | StatusCode = 200, 29 | ContentType = "text/plain", 30 | Content = acmeReq.KeyAuthorization 31 | }; 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /src/Certera.Web/Extensions/ConnectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Connections; 2 | using Microsoft.AspNetCore.Http; 3 | using System.Net; 4 | 5 | namespace Certera.Web.Extensions 6 | { 7 | // adapted from http://stackoverflow.com/a/41242493 8 | public static class ConnectionExtensions 9 | { 10 | public const string NullIPv6 = "::1"; 11 | 12 | public static bool IsLocal(this ConnectionInfo conn) 13 | { 14 | if (!conn.RemoteIpAddress.IsSet()) 15 | { 16 | return true; 17 | } 18 | 19 | // we have a remote address set up 20 | // is local is same as remote, then we are local 21 | if (conn.LocalIpAddress.IsSet()) 22 | { 23 | return conn.RemoteIpAddress.Equals(conn.LocalIpAddress); 24 | } 25 | 26 | // else we are remote if the remote IP address is not a loopback address 27 | return conn.RemoteIpAddress.IsLoopback(); 28 | } 29 | 30 | public static bool IsLocal(this ConnectionContext ctx) 31 | { 32 | var remoteIp = (ctx.RemoteEndPoint as IPEndPoint)?.Address; 33 | if (remoteIp == null || !remoteIp.IsSet()) 34 | { 35 | return true; 36 | } 37 | 38 | var localIp = (ctx.LocalEndPoint as IPEndPoint)?.Address; 39 | if (localIp != null && localIp.IsSet()) 40 | { 41 | return remoteIp.Equals(localIp); 42 | } 43 | 44 | return remoteIp.IsLoopback(); 45 | } 46 | 47 | public static bool IsLocal(this HttpContext ctx) 48 | { 49 | return ctx.Connection.IsLocal(); 50 | } 51 | 52 | public static bool IsLocal(this HttpRequest req) 53 | { 54 | return req.HttpContext.IsLocal(); 55 | } 56 | 57 | public static bool IsSet(this IPAddress address) 58 | { 59 | return address != null && address.ToString() != NullIPv6; 60 | } 61 | 62 | public static bool IsLoopback(this IPAddress address) 63 | { 64 | return IPAddress.IsLoopback(address); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Certera.Web/Extensions/FormFileExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using System.IO; 3 | using System.Text; 4 | using System.Threading.Tasks; 5 | 6 | namespace Certera.Web.Extensions 7 | { 8 | public static class FormFileExtensions 9 | { 10 | public static bool IsNullOrEmpty(this IFormFile file) 11 | { 12 | return file == null || file.Length == 0; 13 | } 14 | 15 | public static async Task ReadAsStringAsync(this IFormFile file) 16 | { 17 | if (file.IsNullOrEmpty()) 18 | { 19 | return null; 20 | } 21 | var result = new StringBuilder(); 22 | using (var reader = new StreamReader(file.OpenReadStream())) 23 | { 24 | while (reader.Peek() >= 0) 25 | { 26 | result.AppendLine(await reader.ReadLineAsync()); 27 | } 28 | } 29 | return result.ToString(); 30 | } 31 | 32 | public static async Task ReadAsBytesAsync(this IFormFile file) 33 | { 34 | if (file.IsNullOrEmpty()) 35 | { 36 | return null; 37 | } 38 | 39 | using (var ms = new MemoryStream()) 40 | { 41 | await file.CopyToAsync(ms); 42 | var fileBytes = ms.ToArray(); 43 | return fileBytes; 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Certera.Web/IdentityHostingStartup.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Hosting; 2 | 3 | [assembly: HostingStartup(typeof(Certera.Web.Areas.Identity.IdentityHostingStartup))] 4 | namespace Certera.Web.Areas.Identity 5 | { 6 | public class IdentityHostingStartup : IHostingStartup 7 | { 8 | public void Configure(IWebHostBuilder builder) 9 | { 10 | builder.ConfigureServices((context, services) => {}); 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/Certera.Web/Middleware/AllowedRemoteIpMiddleware.cs: -------------------------------------------------------------------------------- 1 | using Certera.Web.Extensions; 2 | using Certera.Web.Options; 3 | using Microsoft.AspNetCore.Http; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Options; 6 | using System; 7 | using System.Linq; 8 | using System.Net; 9 | using System.Threading.Tasks; 10 | 11 | namespace Certera.Web.Middleware 12 | { 13 | public class AllowedRemoteIPMiddleware 14 | { 15 | private readonly RequestDelegate _next; 16 | private readonly ILogger _logger; 17 | private readonly IOptionsMonitor _allowedIPs; 18 | 19 | public AllowedRemoteIPMiddleware( 20 | RequestDelegate next, 21 | ILogger logger, 22 | IOptionsMonitor allowedIPs) 23 | { 24 | _allowedIPs = allowedIPs; 25 | _next = next; 26 | _logger = logger; 27 | } 28 | 29 | public async Task Invoke(HttpContext context) 30 | { 31 | // Always allow local connections. 32 | // Also allow access to the acme-challenge endpoint since Let's Encrypt 33 | // does not publish their IP addresses. 34 | if (context.Request.IsLocal() || 35 | context.Request.Path.StartsWithSegments("/.well-known/acme-challenge") || 36 | context.Request.Path.StartsWithSegments("/api/test")) 37 | { 38 | await _next.Invoke(context); 39 | return; 40 | } 41 | 42 | bool isApi = context.Request.Path.StartsWithSegments("/api"); 43 | 44 | string ipList = isApi 45 | ? _allowedIPs.CurrentValue.API 46 | : _allowedIPs.CurrentValue.AdminUI; 47 | 48 | // Allow if value is "wildcard" (denoting any IP) 49 | if (string.Equals("*", ipList)) 50 | { 51 | await _next.Invoke(context); 52 | return; 53 | } 54 | 55 | var remoteIp = context.Connection.RemoteIpAddress; 56 | 57 | // IP may come as ::ffff:, which is an IPv4 mapped to IPv6 58 | if (remoteIp?.IsIPv4MappedToIPv6 == true) 59 | { 60 | remoteIp = remoteIp.MapToIPv4(); 61 | } 62 | 63 | var ips = ipList.Split(',',';', StringSplitOptions.RemoveEmptyEntries); 64 | 65 | var isBadIP = true; 66 | 67 | foreach (var address in ips) 68 | { 69 | var network = IPNetwork.Parse(address, CidrGuess.ClassLess); 70 | if (network.Contains(remoteIp)) 71 | { 72 | isBadIP = false; 73 | break; 74 | } 75 | } 76 | 77 | if (isBadIP) 78 | { 79 | _logger.LogInformation($"Forbidden request from remote IP address: {remoteIp}. Does not match {ipList}"); 80 | context.Response.StatusCode = 401; 81 | return; 82 | } 83 | 84 | await _next.Invoke(context); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Certera.Web/Options/AllowedRemoteIPAddresses.cs: -------------------------------------------------------------------------------- 1 | namespace Certera.Web.Options 2 | { 3 | public class AllowedRemoteIPAddresses 4 | { 5 | public string AdminUI { get; set; } 6 | public string API { get; set; } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Certera.Web/Options/DnsServers.cs: -------------------------------------------------------------------------------- 1 | namespace Certera.Web.Options 2 | { 3 | 4 | public class DnsServers 5 | { 6 | public string[] IPs { get; set; } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Certera.Web/Options/HttpServer.cs: -------------------------------------------------------------------------------- 1 | namespace Certera.Web.Options 2 | { 3 | public class HttpServer 4 | { 5 | public string SiteHostname { get; set; } 6 | public int HttpsPort { get; set; } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Certera.Web/Options/Setup.cs: -------------------------------------------------------------------------------- 1 | namespace Certera.Web.Options 2 | { 3 | public class Setup 4 | { 5 | public bool Finished { get; set; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Certera.Web/Options/WriteableOptions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Hosting; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Hosting; 5 | using Microsoft.Extensions.Options; 6 | using Newtonsoft.Json; 7 | using Newtonsoft.Json.Linq; 8 | using System; 9 | using System.IO; 10 | 11 | namespace Certera.Web.Options 12 | { 13 | public interface IWritableOptions : IOptionsSnapshot where T : class, new() 14 | { 15 | void Update(Action applyChanges); 16 | } 17 | 18 | public class WritableOptions : IWritableOptions where T : class, new() 19 | { 20 | private readonly IHostEnvironment _environment; 21 | private readonly IOptionsMonitor _options; 22 | private readonly string _section; 23 | private readonly string _file; 24 | 25 | public WritableOptions( 26 | IHostEnvironment environment, 27 | IOptionsMonitor options, 28 | string section, 29 | string file) 30 | { 31 | _environment = environment; 32 | _options = options; 33 | _section = section; 34 | _file = file; 35 | } 36 | 37 | public T Value => _options.CurrentValue; 38 | public T Get(string name) => _options.Get(name); 39 | 40 | public void Update(Action applyChanges) 41 | { 42 | var fileProvider = _environment.ContentRootFileProvider; 43 | var fileInfo = fileProvider.GetFileInfo(_file); 44 | var physicalPath = fileInfo.PhysicalPath; 45 | 46 | var jObject = JsonConvert.DeserializeObject(File.ReadAllText(physicalPath)); 47 | var sectionObject = jObject.TryGetValue(_section, out JToken section) ? 48 | JsonConvert.DeserializeObject(section.ToString()) : (Value ?? new T()); 49 | 50 | applyChanges(sectionObject); 51 | 52 | jObject[_section] = JObject.Parse(JsonConvert.SerializeObject(sectionObject)); 53 | File.WriteAllText(physicalPath, JsonConvert.SerializeObject(jObject, Formatting.Indented)); 54 | } 55 | } 56 | 57 | public static class ServiceCollectionExtensions 58 | { 59 | public static void ConfigureWritable( 60 | this IServiceCollection services, 61 | IConfigurationSection section, 62 | string file = null) where T : class, new() 63 | { 64 | if (file == null) 65 | { 66 | file = Program.ConfigFileName; 67 | } 68 | services.Configure(section); 69 | services.AddTransient>(provider => 70 | { 71 | var environment = provider.GetService(); 72 | var options = provider.GetService>(); 73 | return new WritableOptions(environment, options, section.Key, file); 74 | }); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/AccessDenied.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model AccessDeniedModel 3 | @{ 4 | ViewData["Title"] = "Access denied"; 5 | } 6 | 7 |
8 |

@ViewData["Title"]

9 |

You do not have access to this resource.

10 |
11 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/AccessDenied.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.RazorPages; 2 | 3 | namespace Certera.Web.Pages.Account 4 | { 5 | public class AccessDeniedModel : PageModel 6 | { 7 | public void OnGet() 8 | { 9 | 10 | } 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/ForgotPassword.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model ForgotPasswordModel 3 | @{ 4 | ViewData["Title"] = "Forgot your password?"; 5 | } 6 | 7 |

@ViewData["Title"]

8 |

Enter your email.

9 |
10 |
11 |
12 |
13 |
14 |
15 | 16 | 17 | 18 |
19 | 20 |
21 |
22 |
23 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/ForgotPassword.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Certera.Core.Notifications; 2 | using Certera.Data; 3 | using Microsoft.AspNetCore.Authorization; 4 | using Microsoft.AspNetCore.Identity; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.AspNetCore.Mvc.RazorPages; 7 | using Microsoft.Extensions.Options; 8 | using System.ComponentModel.DataAnnotations; 9 | using System.Text.Encodings.Web; 10 | using System.Threading.Tasks; 11 | 12 | namespace Certera.Web.Pages.Account 13 | { 14 | [AllowAnonymous] 15 | public class ForgotPasswordModel : PageModel 16 | { 17 | private readonly UserManager _userManager; 18 | private readonly MailSender _mailSender; 19 | 20 | public ForgotPasswordModel(UserManager userManager, MailSender mailSender, 21 | IOptionsSnapshot mailInfo) 22 | { 23 | _userManager = userManager; 24 | _mailSender = mailSender; 25 | _mailSender.Initialize(mailInfo.Value); 26 | } 27 | 28 | [BindProperty] 29 | public InputModel Input { get; set; } 30 | 31 | public class InputModel 32 | { 33 | [Required] 34 | [EmailAddress] 35 | public string Email { get; set; } 36 | } 37 | 38 | public async Task OnPostAsync() 39 | { 40 | if (ModelState.IsValid) 41 | { 42 | var user = await _userManager.FindByEmailAsync(Input.Email); 43 | if (user == null || !(await _userManager.IsEmailConfirmedAsync(user))) 44 | { 45 | // Don't reveal that the user does not exist or is not confirmed 46 | return RedirectToPage("./ForgotPasswordConfirmation"); 47 | } 48 | 49 | // For more information on how to enable account confirmation and password reset please 50 | // visit https://go.microsoft.com/fwlink/?LinkID=532713 51 | var code = await _userManager.GeneratePasswordResetTokenAsync(user); 52 | var callbackUrl = Url.Page( 53 | "/Account/ResetPassword", 54 | pageHandler: null, 55 | values: new { code }, 56 | protocol: Request.Scheme); 57 | 58 | _mailSender.Send( 59 | "Reset Password", 60 | $"Please reset your password by clicking here.", 61 | Input.Email); 62 | 63 | return RedirectToPage("./ForgotPasswordConfirmation"); 64 | } 65 | 66 | return Page(); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/ForgotPasswordConfirmation.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model ForgotPasswordConfirmation 3 | @{ 4 | ViewData["Title"] = "Forgot password confirmation"; 5 | } 6 | 7 |

@ViewData["Title"]

8 |

9 | Please check your email to reset your password. 10 |

11 | 12 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/ForgotPasswordConfirmation.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Microsoft.AspNetCore.Authorization; 5 | using Microsoft.AspNetCore.Mvc.RazorPages; 6 | 7 | namespace Certera.Web.Pages.Account 8 | { 9 | [AllowAnonymous] 10 | public class ForgotPasswordConfirmation : PageModel 11 | { 12 | public void OnGet() 13 | { 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Lockout.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model LockoutModel 3 | @{ 4 | ViewData["Title"] = "Locked out"; 5 | } 6 | 7 |
8 |

@ViewData["Title"]

9 |

This account has been locked out, please try again later.

10 |
11 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Lockout.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Authorization; 6 | using Microsoft.AspNetCore.Mvc.RazorPages; 7 | 8 | namespace Certera.Web.Pages.Account 9 | { 10 | [AllowAnonymous] 11 | public class LockoutModel : PageModel 12 | { 13 | public void OnGet() 14 | { 15 | 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Login.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model LoginModel 3 | 4 | @{ 5 | ViewData["Title"] = "Log in"; 6 | } 7 | 8 |

9 | Log in 10 |

11 |
12 |
13 |
14 |
15 | 16 | 17 | 18 |
19 |
20 | 21 | 22 | 23 |
24 |
25 |
26 | 30 |
31 |
32 |
33 | 34 |
35 |
36 |

37 | Forgot your password? 38 |

39 |
40 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Login2fa.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model Login2faModel 3 | @{ 4 | ViewData["Title"] = "Two-factor authentication"; 5 | } 6 | 7 |

@ViewData["Title"]

8 |
9 |

Your login is protected with an authenticator app. Enter your authenticator code below.

10 |
11 |
12 |
13 | 14 |
15 |
16 | 17 | 18 | 19 |
20 |
21 |
22 | 26 |
27 |
28 |
29 | 30 |
31 |
32 |
33 |
34 |

35 | Don't have access to your authenticator device? You can 36 | log in with a recovery code. 37 |

38 | 39 | @section Scripts { 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/LoginRecoveryCode.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model LoginRecoveryCodeModel 3 | @{ 4 | ViewData["Title"] = "Recovery code verification"; 5 | } 6 | 7 |

@ViewData["Title"]

8 |
9 |

10 | You have requested to log in with a recovery code. This login will not be remembered until you provide 11 | an authenticator app code at log in or disable 2FA and log in again. 12 |

13 |
14 |
15 |
16 |
17 |
18 | 19 | 20 | 21 |
22 | 23 |
24 |
25 |
26 | 27 | @section Scripts { 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Logout.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model LogoutModel 3 | @{ 4 | ViewData["Title"] = "Log out"; 5 | } 6 | 7 |
8 |

@ViewData["Title"]

9 |

You have successfully logged out of the application.

10 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Logout.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data; 2 | using Microsoft.AspNetCore.Authorization; 3 | using Microsoft.AspNetCore.Identity; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.AspNetCore.Mvc.RazorPages; 6 | using Microsoft.Extensions.Logging; 7 | using System.Threading.Tasks; 8 | 9 | namespace Certera.Web.Pages.Account 10 | { 11 | [AllowAnonymous] 12 | public class LogoutModel : PageModel 13 | { 14 | private readonly SignInManager _signInManager; 15 | private readonly ILogger _logger; 16 | 17 | public LogoutModel(SignInManager signInManager, ILogger logger) 18 | { 19 | _signInManager = signInManager; 20 | _logger = logger; 21 | } 22 | 23 | public void OnGet() 24 | { 25 | } 26 | 27 | public async Task OnPost() 28 | { 29 | await _signInManager.SignOutAsync(); 30 | _logger.LogInformation("User logged out."); 31 | return LocalRedirect("/"); 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/ChangePassword.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model ChangePasswordModel 3 | @{ 4 | ViewData["Title"] = "Change Password"; 5 | ViewData["ActivePage"] = ManageNavPages.ChangePassword; 6 | } 7 | 8 | 9 |

10 | Change Password 11 |

12 |
13 | 14 |
15 |
16 |
17 |
18 | 19 | 20 | 21 |
22 |
23 | 24 | 25 | 26 |
27 |
28 | 29 | 30 | 31 |
32 | 33 |
34 |
35 |
36 | 37 | @section Scripts { 38 | 39 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/Disable2fa.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model Disable2faModel 3 | @{ 4 | ViewData["Title"] = "Disable two-factor authentication (2FA)"; 5 | ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication; 6 | } 7 | 8 | 9 |

@ViewData["Title"]

10 | 11 | 21 | 22 |
23 |
24 | 25 |
26 |
27 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/Disable2fa.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data; 2 | using Microsoft.AspNetCore.Identity; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.AspNetCore.Mvc.RazorPages; 5 | using Microsoft.Extensions.Logging; 6 | using System; 7 | using System.Threading.Tasks; 8 | 9 | namespace Certera.Web.Pages.Account.Manage 10 | { 11 | public class Disable2faModel : PageModel 12 | { 13 | private readonly UserManager _userManager; 14 | private readonly ILogger _logger; 15 | 16 | public Disable2faModel( 17 | UserManager userManager, 18 | ILogger logger) 19 | { 20 | _userManager = userManager; 21 | _logger = logger; 22 | } 23 | 24 | [TempData] 25 | public string StatusMessage { get; set; } 26 | 27 | public async Task OnGet() 28 | { 29 | var user = await _userManager.GetUserAsync(User); 30 | if (user == null) 31 | { 32 | return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); 33 | } 34 | 35 | if (!await _userManager.GetTwoFactorEnabledAsync(user)) 36 | { 37 | throw new InvalidOperationException($"Cannot disable 2FA for user with ID '{_userManager.GetUserId(User)}' as it's not currently enabled."); 38 | } 39 | 40 | return Page(); 41 | } 42 | 43 | public async Task OnPostAsync() 44 | { 45 | var user = await _userManager.GetUserAsync(User); 46 | if (user == null) 47 | { 48 | return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); 49 | } 50 | 51 | var disable2faResult = await _userManager.SetTwoFactorEnabledAsync(user, false); 52 | if (!disable2faResult.Succeeded) 53 | { 54 | throw new InvalidOperationException($"Unexpected error occurred disabling 2FA for user with ID '{_userManager.GetUserId(User)}'."); 55 | } 56 | 57 | _logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", _userManager.GetUserId(User)); 58 | StatusMessage = "2fa has been disabled. You can reenable 2fa when you setup an authenticator app"; 59 | return RedirectToPage("./TwoFactorAuthentication"); 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/EnableAuthenticator.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model EnableAuthenticatorModel 3 | @{ 4 | ViewData["Title"] = "Configure authenticator app"; 5 | ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication; 6 | } 7 | 8 | 9 |

10 | 2-Factor Authentication 11 |

12 |
13 | 14 |
15 |

Scan the QR Code or enter this key @Model.SharedKey into your two factor authenticator app. Spaces and casing do not matter.

16 |
17 |
18 |

19 | Once you have scanned the QR code or input the key above, your two factor authentication app will provide you 20 | with a unique code. Enter the code in the confirmation box below. 21 |

22 |
23 |
24 | 25 | 26 | 27 |
28 | 29 |
30 |
31 |
32 |
33 | 34 | @section Scripts { 35 | 36 | 44 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/GenerateRecoveryCodes.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model GenerateRecoveryCodesModel 3 | @{ 4 | ViewData["Title"] = "2FA codes"; 5 | ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication; 6 | } 7 | 8 | 9 |

@ViewData["Title"]

10 | 23 |
24 |
25 | 26 |
27 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/GenerateRecoveryCodes.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data; 2 | using Microsoft.AspNetCore.Identity; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.AspNetCore.Mvc.RazorPages; 5 | using Microsoft.Extensions.Logging; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Threading.Tasks; 10 | 11 | namespace Certera.Web.Pages.Account.Manage 12 | { 13 | public class GenerateRecoveryCodesModel : PageModel 14 | { 15 | private readonly UserManager _userManager; 16 | private readonly ILogger _logger; 17 | 18 | public GenerateRecoveryCodesModel( 19 | UserManager userManager, 20 | ILogger logger) 21 | { 22 | _userManager = userManager; 23 | _logger = logger; 24 | } 25 | 26 | [TempData] 27 | public IList RecoveryCodes { get; set; } 28 | 29 | [TempData] 30 | public string StatusMessage { get; set; } 31 | 32 | public async Task OnGetAsync() 33 | { 34 | var user = await _userManager.GetUserAsync(User); 35 | if (user == null) 36 | { 37 | return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); 38 | } 39 | 40 | var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user); 41 | if (!isTwoFactorEnabled) 42 | { 43 | var userId = await _userManager.GetUserIdAsync(user); 44 | throw new InvalidOperationException($"Cannot generate recovery codes for user with ID '{userId}' because they do not have 2FA enabled."); 45 | } 46 | 47 | return Page(); 48 | } 49 | 50 | public async Task OnPostAsync() 51 | { 52 | var user = await _userManager.GetUserAsync(User); 53 | if (user == null) 54 | { 55 | return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); 56 | } 57 | 58 | var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user); 59 | var userId = await _userManager.GetUserIdAsync(user); 60 | if (!isTwoFactorEnabled) 61 | { 62 | throw new InvalidOperationException($"Cannot generate recovery codes for user with ID '{userId}' as they do not have 2FA enabled."); 63 | } 64 | 65 | var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); 66 | RecoveryCodes = recoveryCodes.ToList(); 67 | 68 | _logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId); 69 | StatusMessage = "You have generated new recovery codes."; 70 | return RedirectToPage("./ShowRecoveryCodes"); 71 | } 72 | } 73 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/Index.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model IndexModel 3 | @{ 4 | ViewData["Title"] = "Profile"; 5 | ViewData["ActivePage"] = ManageNavPages.Index; 6 | } 7 | 8 |

9 | Profile 10 |

11 |
12 | 13 |
14 |
15 |
16 |
17 | 18 | 19 |
20 |
21 | 22 | 23 | 24 |
25 |
26 | 27 | 28 | 29 |
30 | 31 |
32 |
33 |
34 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/ManageNavPages.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Mvc.Rendering; 3 | 4 | namespace Certera.Web.Pages.Account.Manage 5 | { 6 | public static class ManageNavPages 7 | { 8 | public static string Index => "Index"; 9 | 10 | public static string ChangePassword => "ChangePassword"; 11 | 12 | public static string TwoFactorAuthentication => "TwoFactorAuthentication"; 13 | 14 | public static string IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index); 15 | 16 | public static string ChangePasswordNavClass(ViewContext viewContext) => PageNavClass(viewContext, ChangePassword); 17 | 18 | public static string TwoFactorAuthenticationNavClass(ViewContext viewContext) => PageNavClass(viewContext, TwoFactorAuthentication); 19 | 20 | private static string PageNavClass(ViewContext viewContext, string page) 21 | { 22 | var activePage = viewContext.ViewData["ActivePage"] as string 23 | ?? System.IO.Path.GetFileNameWithoutExtension(viewContext.ActionDescriptor.DisplayName); 24 | return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? "is-active" : null; 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/ResetAuthenticator.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model ResetAuthenticatorModel 3 | @{ 4 | ViewData["Title"] = "Reset authenticator key"; 5 | ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication; 6 | } 7 | 8 | 9 |

@ViewData["Title"]

10 | 20 |
21 |
22 | 23 |
24 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/ResetAuthenticator.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data; 2 | using Microsoft.AspNetCore.Identity; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.AspNetCore.Mvc.RazorPages; 5 | using Microsoft.Extensions.Logging; 6 | using System.Threading.Tasks; 7 | 8 | namespace Certera.Web.Pages.Account.Manage 9 | { 10 | public class ResetAuthenticatorModel : PageModel 11 | { 12 | UserManager _userManager; 13 | private readonly SignInManager _signInManager; 14 | ILogger _logger; 15 | 16 | public ResetAuthenticatorModel( 17 | UserManager userManager, 18 | SignInManager signInManager, 19 | ILogger logger) 20 | { 21 | _userManager = userManager; 22 | _signInManager = signInManager; 23 | _logger = logger; 24 | } 25 | 26 | [TempData] 27 | public string StatusMessage { get; set; } 28 | 29 | public async Task OnGet() 30 | { 31 | var user = await _userManager.GetUserAsync(User); 32 | if (user == null) 33 | { 34 | return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); 35 | } 36 | 37 | return Page(); 38 | } 39 | 40 | public async Task OnPostAsync() 41 | { 42 | var user = await _userManager.GetUserAsync(User); 43 | if (user == null) 44 | { 45 | return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); 46 | } 47 | 48 | await _userManager.SetTwoFactorEnabledAsync(user, false); 49 | await _userManager.ResetAuthenticatorKeyAsync(user); 50 | _logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", user.Id); 51 | 52 | await _signInManager.RefreshSignInAsync(user); 53 | StatusMessage = "Your authenticator app key has been reset, you will need to configure your authenticator app using the new key."; 54 | 55 | return RedirectToPage("./EnableAuthenticator"); 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/ShowRecoveryCodes.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model ShowRecoveryCodesModel 3 | @{ 4 | ViewData["Title"] = "Recovery codes"; 5 | ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication; 6 | } 7 | 8 | 9 |

@ViewData["Title"]

10 | 18 |
19 |
20 | @for (var row = 0; row < Model.RecoveryCodes.Count; row += 2) 21 | { 22 | @Model.RecoveryCodes[row] @Model.RecoveryCodes[row + 1]
23 | } 24 |
25 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/ShowRecoveryCodes.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.AspNetCore.Mvc.RazorPages; 3 | using System.Collections.Generic; 4 | 5 | namespace Certera.Web.Pages.Account.Manage 6 | { 7 | public class ShowRecoveryCodesModel : PageModel 8 | { 9 | 10 | /// 11 | /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used 12 | /// directly from your code. This API may change or be removed in future releases. 13 | /// 14 | [TempData] 15 | public IList RecoveryCodes { get; set; } 16 | 17 | /// 18 | /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used 19 | /// directly from your code. This API may change or be removed in future releases. 20 | /// 21 | [TempData] 22 | public string StatusMessage { get; set; } 23 | 24 | /// 25 | /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used 26 | /// directly from your code. This API may change or be removed in future releases. 27 | /// 28 | public IActionResult OnGet() 29 | { 30 | if (RecoveryCodes == null || RecoveryCodes.Count == 0) 31 | { 32 | return RedirectToPage("./TwoFactorAuthentication"); 33 | } 34 | 35 | return Page(); 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/TwoFactorAuthentication.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model TwoFactorAuthenticationModel 3 | @{ 4 | ViewData["Title"] = "Two-factor authentication (2FA)"; 5 | ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication; 6 | } 7 | 8 | 9 |

10 | 2-Factor Authentication 11 |

12 |
13 | 14 |
15 | @if (Model.Is2faEnabled) 16 | { 17 | if (Model.RecoveryCodesLeft == 0) 18 | { 19 |
20 | You have no recovery codes left. 21 |

You must generate a new set of recovery codes before you can log in with a recovery code.

22 |
23 | } 24 | else if (Model.RecoveryCodesLeft == 1) 25 | { 26 |
27 | You have 1 recovery code left. 28 |

You can generate a new set of recovery codes.

29 |
30 | } 31 | else if (Model.RecoveryCodesLeft <= 3) 32 | { 33 |
34 | You have @Model.RecoveryCodesLeft recovery codes left. 35 |

You should generate a new set of recovery codes.

36 |
37 | } 38 | 39 | if (Model.IsMachineRemembered) 40 | { 41 |
42 | 43 |
44 | } 45 | Disable 2FA 46 | Reset recovery codes 47 | } 48 | 49 |
Authenticator app
50 | @if (!Model.HasAuthenticator) 51 | { 52 | Add authenticator app 53 | } 54 | else 55 | { 56 | 59 | 62 | } 63 | 64 |
65 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/TwoFactorAuthentication.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data; 2 | using Microsoft.AspNetCore.Identity; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.AspNetCore.Mvc.RazorPages; 5 | using System.Threading.Tasks; 6 | 7 | namespace Certera.Web.Pages.Account.Manage 8 | { 9 | public class TwoFactorAuthenticationModel : PageModel 10 | { 11 | private readonly UserManager _userManager; 12 | private readonly SignInManager _signInManager; 13 | 14 | public TwoFactorAuthenticationModel( 15 | UserManager userManager, 16 | SignInManager signInManager) 17 | { 18 | _userManager = userManager; 19 | _signInManager = signInManager; 20 | } 21 | 22 | public bool HasAuthenticator { get; set; } 23 | 24 | public int RecoveryCodesLeft { get; set; } 25 | 26 | [BindProperty] 27 | public bool Is2faEnabled { get; set; } 28 | 29 | public bool IsMachineRemembered { get; set; } 30 | 31 | [TempData] 32 | public string StatusMessage { get; set; } 33 | 34 | public async Task OnGet() 35 | { 36 | var user = await _userManager.GetUserAsync(User); 37 | if (user == null) 38 | { 39 | return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); 40 | } 41 | 42 | HasAuthenticator = await _userManager.GetAuthenticatorKeyAsync(user) != null; 43 | Is2faEnabled = await _userManager.GetTwoFactorEnabledAsync(user); 44 | IsMachineRemembered = await _signInManager.IsTwoFactorClientRememberedAsync(user); 45 | RecoveryCodesLeft = await _userManager.CountRecoveryCodesAsync(user); 46 | 47 | return Page(); 48 | } 49 | 50 | public async Task OnPost() 51 | { 52 | var user = await _userManager.GetUserAsync(User); 53 | if (user == null) 54 | { 55 | return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); 56 | } 57 | 58 | await _signInManager.ForgetTwoFactorClientAsync(); 59 | StatusMessage = "The current browser has been forgotten. When you login again from this browser you will be prompted for your 2fa code."; 60 | return RedirectToPage(); 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/_Layout.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | Layout = "/Pages/Shared/_Layout.cshtml"; 3 | } 4 | 5 | @section Head { 6 | @RenderSection("Head", false) 7 | } 8 |
9 | 10 |
11 | @RenderBody() 12 |
13 |
14 | @section Scripts { 15 | @RenderSection("Scripts", required: false) 16 | } 17 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/_ManageNav.cshtml: -------------------------------------------------------------------------------- 1 |  10 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/_StatusMessage.cshtml: -------------------------------------------------------------------------------- 1 | @model string 2 | 3 | @if (!String.IsNullOrEmpty(Model)) 4 | { 5 | var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success"; 6 | 10 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/_ViewImports.cshtml: -------------------------------------------------------------------------------- 1 | @using Certera.Web.Pages.Account.Manage 2 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/Manage/_ViewStart.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | Layout = "_Layout"; 3 | } 4 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/ResetPassword.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model ResetPasswordModel 3 | @{ 4 | ViewData["Title"] = "Reset password"; 5 | } 6 | 7 |

@ViewData["Title"]

8 |

Reset your password.

9 |
10 |
11 |
12 |
13 |
14 | 15 |
16 | 17 | 18 | 19 |
20 |
21 | 22 | 23 | 24 |
25 |
26 | 27 | 28 | 29 |
30 | 31 |
32 |
33 |
34 | 35 | @section Scripts { 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/ResetPassword.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel.DataAnnotations; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Authorization; 7 | using Certera.Data; 8 | using Microsoft.AspNetCore.Identity; 9 | using Microsoft.AspNetCore.Mvc; 10 | using Microsoft.AspNetCore.Mvc.RazorPages; 11 | 12 | namespace Certera.Web.Pages.Account 13 | { 14 | [AllowAnonymous] 15 | public class ResetPasswordModel : PageModel 16 | { 17 | private readonly UserManager _userManager; 18 | 19 | public ResetPasswordModel(UserManager userManager) 20 | { 21 | _userManager = userManager; 22 | } 23 | 24 | [BindProperty] 25 | public InputModel Input { get; set; } 26 | 27 | public class InputModel 28 | { 29 | [Required] 30 | [EmailAddress] 31 | public string Email { get; set; } 32 | 33 | [Required] 34 | [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] 35 | [DataType(DataType.Password)] 36 | public string Password { get; set; } 37 | 38 | [DataType(DataType.Password)] 39 | [Display(Name = "Confirm password")] 40 | [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] 41 | public string ConfirmPassword { get; set; } 42 | 43 | public string Code { get; set; } 44 | } 45 | 46 | public IActionResult OnGet(string code = null) 47 | { 48 | if (code == null) 49 | { 50 | return BadRequest("A code must be supplied for password reset."); 51 | } 52 | else 53 | { 54 | Input = new InputModel 55 | { 56 | Code = code 57 | }; 58 | return Page(); 59 | } 60 | } 61 | 62 | public async Task OnPostAsync() 63 | { 64 | if (!ModelState.IsValid) 65 | { 66 | return Page(); 67 | } 68 | 69 | var user = await _userManager.FindByEmailAsync(Input.Email); 70 | if (user == null) 71 | { 72 | // Don't reveal that the user does not exist 73 | return RedirectToPage("./ResetPasswordConfirmation"); 74 | } 75 | 76 | var result = await _userManager.ResetPasswordAsync(user, Input.Code, Input.Password); 77 | if (result.Succeeded) 78 | { 79 | return RedirectToPage("./ResetPasswordConfirmation"); 80 | } 81 | 82 | foreach (var error in result.Errors) 83 | { 84 | ModelState.AddModelError(string.Empty, error.Description); 85 | } 86 | return Page(); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/ResetPasswordConfirmation.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model ResetPasswordConfirmationModel 3 | @{ 4 | ViewData["Title"] = "Reset password confirmation"; 5 | } 6 | 7 |

@ViewData["Title"]

8 |

9 | Your password has been reset. Please click here to log in. 10 |

11 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/ResetPasswordConfirmation.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Authorization; 6 | using Microsoft.AspNetCore.Mvc.RazorPages; 7 | 8 | namespace Certera.Web.Pages.Account 9 | { 10 | [AllowAnonymous] 11 | public class ResetPasswordConfirmationModel : PageModel 12 | { 13 | public void OnGet() 14 | { 15 | 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/_Layout.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | Layout = "/Pages/Shared/_Layout.cshtml"; 3 | } 4 | 5 | @section Head { 6 | @RenderSection("Head", false) 7 | } 8 | 9 |
10 |
11 |
12 | @RenderBody() 13 |
14 |
15 |
16 | 17 | @section Scripts { 18 | @RenderSection("Scripts", required: false) 19 | } 20 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/_ViewImports.cshtml: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Identity 2 | @using Certera.Web.Areas.Identity 3 | @using Certera.Data 4 | @namespace Certera.Web.Pages.Account 5 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 6 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Account/_ViewStart.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | Layout = "_Layout.cshtml"; 3 | } 4 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Acme/Accounts/Create.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model Certera.Web.Pages.Acme.Accounts.CreateModel 3 | 4 | @{ 5 | ViewData["Title"] = "Create"; 6 | ViewData["ActivePage"] = ManageNavPages.Acme; 7 | } 8 | 9 |
10 | ACME Accounts - Create 11 |
12 |
13 |
14 | List 15 |
16 |
17 |
18 | 19 | 20 | 21 |
22 |
23 | 27 | 28 |
29 |
30 | 34 |
35 |
36 | 39 | @await Component.InvokeAsync("KeySelect", new { name = "AcmeAccount.KeyId" }) 40 |
41 |
42 | 43 |
44 |
45 |
46 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Acme/Accounts/Delete.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model Certera.Web.Pages.Acme.Accounts.DeleteModel 3 | 4 | @{ 5 | ViewData["Title"] = "Delete"; 6 | ViewData["ActivePage"] = ManageNavPages.Acme; 7 | } 8 | 9 |
10 | ACME Accounts - Delete 11 |
12 |
13 | 14 |
15 | Edit 16 |

Are you sure you want to delete this?

17 |
18 |
19 | @Html.DisplayNameFor(model => model.AcmeAccount.AcmeContactEmail) 20 |
21 |
22 | @Html.DisplayFor(model => model.AcmeAccount.AcmeContactEmail) 23 |
24 |
25 | @Html.DisplayNameFor(model => model.AcmeAccount.Key) 26 |
27 |
28 | @Html.DisplayFor(model => model.AcmeAccount.Key.Name) 29 |
30 |
31 | 32 |
33 | 34 | 35 |
36 |
37 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Acme/Accounts/Delete.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data.Models; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.AspNetCore.Mvc.RazorPages; 4 | using Microsoft.EntityFrameworkCore; 5 | using System.Threading.Tasks; 6 | 7 | namespace Certera.Web.Pages.Acme.Accounts 8 | { 9 | public class DeleteModel : PageModel 10 | { 11 | private readonly Certera.Data.DataContext _context; 12 | 13 | public DeleteModel(Certera.Data.DataContext context) 14 | { 15 | _context = context; 16 | } 17 | 18 | [BindProperty] 19 | public AcmeAccount AcmeAccount { get; set; } 20 | [TempData] 21 | public string StatusMessage { get; set; } 22 | 23 | public async Task OnGetAsync(long? id) 24 | { 25 | if (id == null) 26 | { 27 | return NotFound(); 28 | } 29 | 30 | AcmeAccount = await _context.AcmeAccounts 31 | .Include(a => a.ApplicationUser) 32 | .Include(a => a.Key).FirstOrDefaultAsync(m => m.AcmeAccountId == id); 33 | 34 | if (AcmeAccount == null) 35 | { 36 | return NotFound(); 37 | } 38 | return Page(); 39 | } 40 | 41 | public async Task OnPostAsync(long? id) 42 | { 43 | if (id == null) 44 | { 45 | return NotFound(); 46 | } 47 | 48 | AcmeAccount = await _context.AcmeAccounts 49 | .Include(a => a.ApplicationUser) 50 | .Include(a => a.Key).FirstOrDefaultAsync(m => m.AcmeAccountId == id); 51 | 52 | if (AcmeAccount != null) 53 | { 54 | _context.AcmeAccounts.Remove(AcmeAccount); 55 | try 56 | { 57 | await _context.SaveChangesAsync(); 58 | StatusMessage = "ACME account deleted"; 59 | } 60 | catch (DbUpdateException) 61 | { 62 | StatusMessage = "Unable to delete ACME account in use"; 63 | return Page(); 64 | } 65 | } 66 | 67 | return RedirectToPage("./Index"); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Acme/Accounts/Edit.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model Certera.Web.Pages.Acme.Accounts.EditModel 3 | 4 | @{ 5 | ViewData["Title"] = "Edit"; 6 | ViewData["ActivePage"] = ManageNavPages.Acme; 7 | } 8 | 9 |
10 | ACME Accounts - Edit 11 |
12 |
13 |
14 | ACME Accounts 15 | Delete 16 |
17 |
18 | 19 |
20 | 21 | 22 | 23 |
24 |
25 | 28 |
29 |
30 | 31 | @await Component.InvokeAsync("KeySelect", new { name = "AcmeAccount.KeyId", selected = @Model.AcmeAccount.KeyId, hideGenNewKeyOption = true }) 32 | 33 |
34 |
35 | 36 |
37 |
38 |
39 |
40 | 41 |
42 | Back to List 43 |
44 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Acme/Accounts/Edit.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.AspNetCore.Mvc.RazorPages; 7 | using Microsoft.AspNetCore.Mvc.Rendering; 8 | using Microsoft.EntityFrameworkCore; 9 | using Certera.Data; 10 | using Certera.Data.Models; 11 | 12 | namespace Certera.Web.Pages.Acme.Accounts 13 | { 14 | public class EditModel : PageModel 15 | { 16 | private readonly Certera.Data.DataContext _context; 17 | 18 | public EditModel(Certera.Data.DataContext context) 19 | { 20 | _context = context; 21 | } 22 | 23 | [BindProperty] 24 | public AcmeAccount AcmeAccount { get; set; } 25 | [TempData] 26 | public string StatusMessage { get; set; } 27 | 28 | public async Task OnGetAsync(long? id) 29 | { 30 | if (id == null) 31 | { 32 | return NotFound(); 33 | } 34 | 35 | AcmeAccount = await _context.AcmeAccounts 36 | .Include(a => a.ApplicationUser) 37 | .Include(a => a.Key).FirstOrDefaultAsync(m => m.AcmeAccountId == id); 38 | 39 | if (AcmeAccount == null) 40 | { 41 | return NotFound(); 42 | } 43 | ViewData["ApplicationUserId"] = new SelectList(_context.ApplicationUsers, "Id", "Id"); 44 | return Page(); 45 | } 46 | 47 | public async Task OnPostAsync() 48 | { 49 | if (!ModelState.IsValid) 50 | { 51 | return Page(); 52 | } 53 | 54 | _context.Attach(AcmeAccount).State = EntityState.Modified; 55 | 56 | try 57 | { 58 | await _context.SaveChangesAsync(); 59 | StatusMessage = "Account updated"; 60 | } 61 | catch (DbUpdateConcurrencyException) 62 | { 63 | if (!AcmeAccountExists(AcmeAccount.AcmeAccountId)) 64 | { 65 | return NotFound(); 66 | } 67 | else 68 | { 69 | throw; 70 | } 71 | } 72 | 73 | return RedirectToPage("./Index"); 74 | } 75 | 76 | private bool AcmeAccountExists(long id) 77 | { 78 | return _context.AcmeAccounts.Any(e => e.AcmeAccountId == id); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Acme/Accounts/Index.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model Certera.Web.Pages.Acme.Accounts.IndexModel 3 | 4 | @{ 5 | ViewData["Title"] = "ACME Acct."; 6 | ViewData["ActivePage"] = ManageNavPages.Acme; 7 | } 8 | 9 |
10 | ACME Accounts 11 |
12 |
13 | 14 |
15 | Create 16 | 17 | 18 | 19 | 22 | 25 | 28 | 29 | 30 | 31 | @foreach (var item in Model.AcmeAccount) 32 | { 33 | 34 | 43 | 46 | 49 | 50 | } 51 | 52 |
20 | ACME Email 21 | 23 | ToS 24 | 26 | @Html.DisplayNameFor(model => model.AcmeAccount[0].Key) 27 |
35 | 38 | @if (item.IsAcmeStaging) 39 | { 40 | staging 41 | } 42 | 44 | accepted 45 | 47 | @item.Key.Name 48 |
53 |
54 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Acme/Accounts/Index.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.AspNetCore.Mvc.RazorPages; 7 | using Microsoft.EntityFrameworkCore; 8 | using Certera.Data; 9 | using Certera.Data.Models; 10 | 11 | namespace Certera.Web.Pages.Acme.Accounts 12 | { 13 | public class IndexModel : PageModel 14 | { 15 | private readonly Certera.Data.DataContext _context; 16 | 17 | public IndexModel(Certera.Data.DataContext context) 18 | { 19 | _context = context; 20 | } 21 | 22 | public IList AcmeAccount { get;set; } 23 | [TempData] 24 | public string StatusMessage { get; set; } 25 | 26 | public async Task OnGetAsync() 27 | { 28 | AcmeAccount = await _context.AcmeAccounts 29 | .Include(a => a.ApplicationUser) 30 | .Include(a => a.Key).ToListAsync(); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Certificates/Create.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.AspNetCore.Mvc.RazorPages; 7 | using Microsoft.AspNetCore.Mvc.Rendering; 8 | using Certera.Data; 9 | using Certera.Data.Models; 10 | using Certera.Web.Services; 11 | using Certes; 12 | 13 | namespace Certera.Web.Pages.Certificates 14 | { 15 | public class CreateModel : PageModel 16 | { 17 | private readonly Certera.Data.DataContext _context; 18 | private readonly KeyGenerator _keyGenerator; 19 | 20 | public CreateModel(Certera.Data.DataContext context, KeyGenerator keyGenerator) 21 | { 22 | _context = context; 23 | _keyGenerator = keyGenerator; 24 | } 25 | 26 | public IActionResult OnGet() 27 | { 28 | LoadData(); 29 | return Page(); 30 | } 31 | 32 | private void LoadData() 33 | { 34 | ViewData["AcmeAccountId"] = new SelectList( 35 | _context.AcmeAccounts.Select(x => new 36 | { 37 | Id = x.AcmeAccountId, 38 | Name = x.AcmeContactEmail + (x.IsAcmeStaging ? " (staging)" : string.Empty) 39 | }) 40 | , "Id", "Name"); 41 | } 42 | 43 | [BindProperty] 44 | public AcmeCertificate AcmeCertificate { get; set; } 45 | 46 | [TempData] 47 | public string StatusMessage { get; set; } 48 | 49 | public async Task OnPostAsync() 50 | { 51 | if (!ModelState.IsValid) 52 | { 53 | LoadData(); 54 | return Page(); 55 | } 56 | 57 | if (AcmeCertificate.KeyId < 0) 58 | { 59 | var key = _keyGenerator.Generate(AcmeCertificate.Subject, KeyAlgorithm.RS256); 60 | if (key == null) 61 | { 62 | ModelState.AddModelError(string.Empty, "Error creating key"); 63 | return Page(); 64 | } 65 | 66 | AcmeCertificate.KeyId = key.KeyId; 67 | } 68 | 69 | AcmeCertificate.ApiKey1 = ApiKeyGenerator.CreateApiKey(); 70 | AcmeCertificate.ApiKey2 = ApiKeyGenerator.CreateApiKey(); 71 | 72 | _context.AcmeCertificates.Add(AcmeCertificate); 73 | await _context.SaveChangesAsync(); 74 | 75 | StatusMessage = "Certificate created"; 76 | 77 | return RedirectToPage("./Index"); 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Certificates/Delete.cshtml: -------------------------------------------------------------------------------- 1 | @page "{id}" 2 | @model Certera.Web.Pages.Certificates.DeleteModel 3 | 4 | @{ 5 | ViewData["Title"] = "Delete"; 6 | ViewData["ActivePage"] = ManageNavPages.Certificates; 7 | } 8 | 9 |
10 | Certificates - Delete 11 |
12 |
13 | 14 |
15 | Edit 16 |
17 |
18 | @Html.DisplayNameFor(model => model.AcmeCertificate.Name) 19 |
20 |
21 |
@Html.DisplayFor(model => model.AcmeCertificate.Name)
22 | @if (Model.AcmeCertificate.AcmeAccount.IsAcmeStaging) 23 | { 24 | staging 25 | } 26 | @Html.DisplayFor(model => model.AcmeCertificate.ChallengeType) 27 |
28 |
29 | @Html.DisplayNameFor(model => model.AcmeCertificate.Subject) 30 |
31 |
32 |
33 | @Html.DisplayFor(model => model.AcmeCertificate.Subject) 34 |
35 | @if (!string.IsNullOrWhiteSpace(Model.AcmeCertificate.SANs)) 36 | { 37 | @Html.DisplayFor(modelItem => Model.AcmeCertificate.SANs) 38 | } 39 |
40 |
41 | @Html.DisplayNameFor(model => model.AcmeCertificate.DateCreated) 42 |
43 |
44 | @Model.AcmeCertificate.DateCreated.ToLocalTime().ToString() 45 |
46 |
47 | 48 |
49 | 50 | 51 |
52 |
53 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Certificates/Delete.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.AspNetCore.Mvc.RazorPages; 7 | using Microsoft.EntityFrameworkCore; 8 | using Certera.Data; 9 | using Certera.Data.Models; 10 | using Microsoft.Extensions.Options; 11 | using Certera.Web.Options; 12 | 13 | namespace Certera.Web.Pages.Certificates 14 | { 15 | public class DeleteModel : PageModel 16 | { 17 | private readonly Certera.Data.DataContext _context; 18 | private readonly IOptionsSnapshot _httpServerOptions; 19 | 20 | public DeleteModel(Certera.Data.DataContext context, IOptionsSnapshot httpServerOptions) 21 | { 22 | _context = context; 23 | _httpServerOptions = httpServerOptions; 24 | } 25 | 26 | [BindProperty] 27 | public AcmeCertificate AcmeCertificate { get; set; } 28 | [TempData] 29 | public string StatusMessage { get; set; } 30 | 31 | public async Task OnGetAsync(long? id) 32 | { 33 | if (id == null) 34 | { 35 | return NotFound(); 36 | } 37 | 38 | AcmeCertificate = await _context.AcmeCertificates 39 | .Include(a => a.AcmeAccount) 40 | .Include(a => a.Key) 41 | .FirstOrDefaultAsync(m => m.AcmeCertificateId == id); 42 | 43 | if (AcmeCertificate == null) 44 | { 45 | return NotFound(); 46 | } 47 | return Page(); 48 | } 49 | 50 | public async Task OnPostAsync(long? id) 51 | { 52 | if (id == null) 53 | { 54 | return NotFound(); 55 | } 56 | 57 | AcmeCertificate = await _context.AcmeCertificates 58 | .Include(a => a.AcmeAccount) 59 | .Include(a => a.Key) 60 | .FirstOrDefaultAsync(m => m.AcmeCertificateId == id); 61 | 62 | if (AcmeCertificate == null) 63 | { 64 | return NotFound(); 65 | } 66 | // Prevent deleting of site certificate 67 | if (AcmeCertificate.Subject == _httpServerOptions.Value.SiteHostname) 68 | { 69 | StatusMessage = "Cannot delete site certificate"; 70 | return Page(); 71 | } 72 | 73 | _context.AcmeCertificates.Remove(AcmeCertificate); 74 | await _context.SaveChangesAsync(); 75 | 76 | StatusMessage = "Certificate deleted"; 77 | 78 | return RedirectToPage("./Index"); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Certificates/Index.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model Certera.Web.Pages.Certificates.IndexModel 3 | 4 | @{ 5 | ViewData["Title"] = "Certificates"; 6 | ViewData["ActivePage"] = ManageNavPages.Certificates; 7 | } 8 | 9 |
10 | Certificates 11 |
12 |
13 | 14 |
15 | Create 16 | 17 | 18 | 19 | 22 | 25 | 28 | 31 | 32 | 33 | 34 | @foreach (var item in Model.AcmeCertificate) 35 | { 36 | 37 | 49 | 58 | 61 | 64 | 65 | } 66 | 67 |
20 | @Html.DisplayNameFor(model => model.AcmeCertificate[0].Name) 21 | 23 | @Html.DisplayNameFor(model => model.AcmeCertificate[0].Subject) 24 | 26 | @Html.DisplayNameFor(model => model.AcmeCertificate[0].DateCreated) 27 | 29 | @Html.DisplayNameFor(model => model.AcmeCertificate[0].Key) 30 |
38 | 41 |
42 | @if (item.AcmeAccount.IsAcmeStaging) 43 | { 44 | staging 45 | } 46 | @Html.DisplayFor(modelItem => item.ChallengeType) 47 |
48 |
50 |
51 | @Html.DisplayFor(modelItem => item.Subject) 52 |
53 | @if (!string.IsNullOrWhiteSpace(item.SANs)) 54 | { 55 | @Html.DisplayFor(modelItem => item.SANs) 56 | } 57 |
59 | @item.DateCreated.ToLocalTime().ToString() 60 | 62 | @item.Key.Name 63 |
68 |
69 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Certificates/Index.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data.Models; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.AspNetCore.Mvc.RazorPages; 4 | using Microsoft.EntityFrameworkCore; 5 | using System.Collections.Generic; 6 | using System.Threading.Tasks; 7 | 8 | namespace Certera.Web.Pages.Certificates 9 | { 10 | public class IndexModel : PageModel 11 | { 12 | private readonly Certera.Data.DataContext _context; 13 | 14 | public IndexModel(Certera.Data.DataContext context) 15 | { 16 | _context = context; 17 | } 18 | 19 | public IList AcmeCertificate { get;set; } 20 | 21 | [TempData] 22 | public string StatusMessage { get; set; } 23 | 24 | public async Task OnGetAsync() 25 | { 26 | AcmeCertificate = await _context.AcmeCertificates 27 | .Include(a => a.AcmeAccount) 28 | .Include(a => a.Key).ToListAsync(); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Certificates/Request.cshtml: -------------------------------------------------------------------------------- 1 | @page "{id}" 2 | @model Certera.Web.Pages.Certificates.RequestModel 3 | @* 4 | For more information on enabling MVC for empty projects, visit http://go.microsoft.com/fwlink/?LinkID=397860 5 | *@ 6 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Certificates/Request.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Certera.Web.Services; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.AspNetCore.Mvc.RazorPages; 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | namespace Certera.Web.Pages.Certificates 7 | { 8 | public class RequestModel : PageModel 9 | { 10 | private readonly IBackgroundTaskQueue _queue; 11 | private readonly IServiceScopeFactory _serviceScopeFactory; 12 | 13 | public RequestModel(IBackgroundTaskQueue queue, IServiceScopeFactory serviceScopeFactory) 14 | { 15 | _queue = queue; 16 | _serviceScopeFactory = serviceScopeFactory; 17 | } 18 | 19 | [TempData] 20 | public string StatusMessage { get; set; } 21 | 22 | public IActionResult OnGet(long? id = null, string returnUrl = null) 23 | { 24 | if (id != null) 25 | { 26 | _queue.QueueBackgroundWorkItem(async token => 27 | { 28 | var localId = id.Value; 29 | 30 | using (var scope = _serviceScopeFactory.CreateScope()) 31 | { 32 | var acquirer = scope.ServiceProvider.GetService(); 33 | await acquirer.AcquireAcmeCert(localId, userRequested: true); 34 | } 35 | }); 36 | StatusMessage = "Certificate requested"; 37 | } 38 | 39 | return new RedirectResult(returnUrl ?? Url.Page("./Index")); 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Components/ChallengeSelect/default.cshtml: -------------------------------------------------------------------------------- 1 | @model Certera.Web.Pages.Components.ChallengeSelect.ChallengeSelectModel 2 | @{ 3 | } 4 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Components/ChallengeSelect/default.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.AspNetCore.Mvc.Rendering; 4 | using System.Collections.Generic; 5 | 6 | namespace Certera.Web.Pages.Components.ChallengeSelect 7 | { 8 | public class ChallengeSelectViewComponent : ViewComponent 9 | { 10 | private readonly DataContext _context; 11 | 12 | public ChallengeSelectViewComponent(DataContext context) 13 | { 14 | _context = context; 15 | } 16 | 17 | public IViewComponentResult Invoke(string name, string selected = null) 18 | { 19 | var setScript = _context.GetSetting(Data.Settings.Dns01SetScript, null); 20 | var cleanupScript = _context.GetSetting(Data.Settings.Dns01CleanupScript, null); 21 | 22 | var disabled = false; 23 | 24 | if (string.IsNullOrWhiteSpace(setScript) || string.IsNullOrWhiteSpace(cleanupScript)) 25 | { 26 | disabled = true; 27 | selected = "http-01"; 28 | } 29 | 30 | var selectListItems = new List 31 | { 32 | new SelectListItem 33 | { 34 | Text = "HTTP-01", 35 | Value = "http-01", 36 | Disabled = disabled 37 | }, 38 | new SelectListItem 39 | { 40 | Text = "DNS-01", 41 | Value = "dns-01", 42 | Disabled = disabled 43 | }, 44 | }; 45 | 46 | foreach (var item in selectListItems) 47 | { 48 | if (string.Equals(item.Value, selected, System.StringComparison.OrdinalIgnoreCase)) 49 | { 50 | item.Selected = true; 51 | break; 52 | } 53 | } 54 | 55 | return View(new ChallengeSelectModel { Name = name, Items = selectListItems }); 56 | } 57 | } 58 | 59 | public class ChallengeSelectModel 60 | { 61 | public string Name { get; set; } 62 | public List Items { get; set; } 63 | } 64 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Components/KeySelect/default.cshtml: -------------------------------------------------------------------------------- 1 | @model Certera.Web.Pages.Components.KeySelect.KeySelectModel 2 | @{ 3 | } 4 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Components/KeySelect/default.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.AspNetCore.Mvc.Rendering; 4 | using System.Collections.Generic; 5 | 6 | namespace Certera.Web.Pages.Components.KeySelect 7 | { 8 | public class KeySelectViewComponent : ViewComponent 9 | { 10 | private readonly DataContext _context; 11 | 12 | public KeySelectViewComponent(DataContext context) 13 | { 14 | _context = context; 15 | } 16 | 17 | public IViewComponentResult Invoke(string name, long? selected = null, bool? hideGenNewKeyOption = false) 18 | { 19 | var keysList = new SelectList(_context.Keys, "KeyId", "Name"); 20 | var newKeyList = new List(); 21 | if (hideGenNewKeyOption == null || hideGenNewKeyOption.Value == false) 22 | { 23 | newKeyList.Add(new SelectListItem { Text = "Generate & re-use new key", Value = "-1" }); 24 | } 25 | newKeyList.AddRange(keysList); 26 | foreach (var item in newKeyList) 27 | { 28 | if (item.Value == selected?.ToString()) 29 | { 30 | item.Selected = true; 31 | break; 32 | } 33 | } 34 | return View(new KeySelectModel { Name = name, Items = newKeyList }); 35 | } 36 | } 37 | 38 | public class KeySelectModel 39 | { 40 | public string Name { get; set; } 41 | public List Items { get; set; } 42 | } 43 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Error.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model ErrorModel 3 | @{ 4 | ViewData["Title"] = "Error"; 5 | } 6 | 7 |

Error.

8 |

An error occurred while processing your request.

9 | 10 | @if (Model.ShowRequestId) 11 | { 12 |

13 | Request ID: @Model.RequestId 14 |

15 | } 16 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Error.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.AspNetCore.Mvc.RazorPages; 3 | using System.Diagnostics; 4 | 5 | namespace Certera.Web.Pages 6 | { 7 | [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] 8 | public class ErrorModel : PageModel 9 | { 10 | public string RequestId { get; set; } 11 | 12 | public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); 13 | 14 | public void OnGet() 15 | { 16 | RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Index.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model IndexModel 3 | @{ 4 | ViewData["Title"] = "Home"; 5 | } 6 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Index.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.RazorPages; 2 | 3 | namespace Certera.Web.Pages 4 | { 5 | public class IndexModel : PageModel 6 | { 7 | public void OnGet() 8 | { 9 | // There's nothing on the root page (yet). Reserve it so we can maybe have a dashboard or something. 10 | HttpContext.Response.Redirect("/tracking"); 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Keys/Create.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model Certera.Web.Pages.Keys.CreateModel 3 | 4 | @{ 5 | ViewData["Title"] = "Create"; 6 | ViewData["ActivePage"] = ManageNavPages.Keys; 7 | } 8 | 9 |
10 | Keys - Create 11 |
12 |
13 |
14 | List 15 |
16 |
17 |
18 | 19 | 20 | 21 |
22 |
23 | 24 | 25 | 26 |
27 | @*
28 | 29 |
30 | (A reminder to rotate this key) 31 |
32 | 33 | 34 |
*@ 35 |

Choose one of the following

36 |

1. Generate a key

37 |
38 | 39 | 46 |
47 | 48 |

2. Enter key contents (PEM)

49 |

Enter a key file in PEM format or upload a file.

50 |
51 | 52 | 53 | 54 |
55 | 56 |

3. Upload a PEM or DER encoded key

57 |
58 | 59 | 60 | 61 |
62 |
63 | 64 |
65 |
66 |
67 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Keys/Delete.cshtml: -------------------------------------------------------------------------------- 1 | @page "{id}" 2 | @model Certera.Web.Pages.Keys.DeleteModel 3 | 4 | @{ 5 | ViewData["Title"] = "Delete"; 6 | ViewData["ActivePage"] = ManageNavPages.Keys; 7 | } 8 | 9 |
10 | Keys - Delete 11 |
12 |
13 | 14 |
15 | Edit 16 |

Are you sure you want to delete this?

17 |
18 |
19 | @Html.DisplayNameFor(model => model.Key.Name) 20 |
21 |
22 | @Html.DisplayFor(model => model.Key.Name) 23 |
24 |
25 | @Html.DisplayNameFor(model => model.Key.Description) 26 |
27 |
28 | @Html.DisplayFor(model => model.Key.Description) 29 |
30 |
31 | @Html.DisplayNameFor(model => model.Key.DateCreated) 32 |
33 |
34 | @Model.Key.DateCreated.ToLocalTime().ToString() 35 |
36 |
37 | @Html.DisplayNameFor(model => model.Key.Algorithm) 38 |
39 |
40 | @Model.Key.Algorithm 41 |
42 |
43 |
44 | 45 | 46 |
47 |
48 |
49 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Keys/Delete.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.AspNetCore.Mvc.RazorPages; 7 | using Microsoft.EntityFrameworkCore; 8 | using Certera.Data; 9 | using Certera.Data.Models; 10 | 11 | namespace Certera.Web.Pages.Keys 12 | { 13 | public class DeleteModel : PageModel 14 | { 15 | private readonly Certera.Data.DataContext _context; 16 | 17 | public DeleteModel(Certera.Data.DataContext context) 18 | { 19 | _context = context; 20 | } 21 | 22 | [BindProperty] 23 | public Key Key { get; set; } 24 | 25 | [TempData] 26 | public string StatusMessage { get; set; } 27 | 28 | public async Task OnGetAsync(long? id) 29 | { 30 | if (id == null) 31 | { 32 | return NotFound(); 33 | } 34 | 35 | Key = await _context.Keys.FirstOrDefaultAsync(m => m.KeyId == id); 36 | 37 | if (Key == null) 38 | { 39 | return NotFound(); 40 | } 41 | return Page(); 42 | } 43 | 44 | public async Task OnPostAsync(long? id) 45 | { 46 | if (id == null) 47 | { 48 | return NotFound(); 49 | } 50 | 51 | Key = await _context.Keys.FindAsync(id); 52 | 53 | if (Key != null) 54 | { 55 | _context.Keys.Remove(Key); 56 | try 57 | { 58 | await _context.SaveChangesAsync(); 59 | StatusMessage = "Key deleted"; 60 | } 61 | catch (DbUpdateException) 62 | { 63 | StatusMessage = "Unable to delete key in use"; 64 | return Page(); 65 | } 66 | } 67 | 68 | return RedirectToPage("./Index"); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Keys/Edit.cshtml: -------------------------------------------------------------------------------- 1 | @page "{id}" 2 | @model Certera.Web.Pages.Keys.EditModel 3 | 4 | @{ 5 | ViewData["Title"] = "Edit"; 6 | ViewData["ActivePage"] = ManageNavPages.Keys; 7 | } 8 | 9 |
10 | Keys - Edit 11 |
12 |
13 |
14 | Keys 15 | Delete 16 |
17 |
18 | 19 |
20 | 21 | 22 | 23 |
24 |
25 | 26 | 27 | 28 |
29 |
30 | @Model.Key.Algorithm) 31 |
32 | @*
33 | 34 | 35 | 36 |
*@ 37 |
38 | 39 |
40 |
41 |
42 |
43 |
44 |
45 | @Html.DisplayNameFor(model => model.Key.ApiKey1) 46 |
47 |
48 |
49 | @Html.DisplayFor(model => model.Key.ApiKey1) 50 |
51 |
52 | 53 | 54 | 55 |
56 |
57 |
58 | @Html.DisplayNameFor(model => model.Key.ApiKey2) 59 |
60 |
61 |
62 | @Html.DisplayFor(model => model.Key.ApiKey2) 63 |
64 |
65 | 66 | 67 | 68 |
69 |
70 |
71 |
72 |
73 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Keys/Index.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model Certera.Web.Pages.Keys.IndexModel 3 | 4 | @{ 5 | ViewData["Title"] = "Keys"; 6 | ViewData["ActivePage"] = ManageNavPages.Keys; 7 | } 8 | 9 |
10 | Keys 11 |
12 |
13 | 14 |
15 | Create 16 | 17 | 18 | 19 | 20 | 21 | 22 | @**@ 23 | 24 | 25 | 26 | @foreach (var item in Model.Key) 27 | { 28 | 29 | 35 | 38 | 41 | @**@ 44 | 45 | } 46 | 47 |
@Html.DisplayNameFor(model => model.Key[0].Name)@Html.DisplayNameFor(model => model.Key[0].Description)@Html.DisplayNameFor(model => model.Key[0].DateCreated)@Html.DisplayNameFor(model => model.Key[0].DateRotationReminder)
30 |
31 | @item.Name 32 |
33 | @Html.DisplayFor(modelItem => item.Algorithm) 34 |
36 | @Html.DisplayFor(modelItem => item.Description) 37 | 39 | @item.DateCreated.ToLocalTime().ToString() 40 | 42 | @Html.DisplayFor(modelItem => item.DateRotationReminder) 43 |
48 |
49 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Keys/Index.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data.Models; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.AspNetCore.Mvc.RazorPages; 4 | using Microsoft.EntityFrameworkCore; 5 | using System.Collections.Generic; 6 | using System.Threading.Tasks; 7 | 8 | namespace Certera.Web.Pages.Keys 9 | { 10 | public class IndexModel : PageModel 11 | { 12 | private readonly Certera.Data.DataContext _context; 13 | 14 | public IndexModel(Certera.Data.DataContext context) 15 | { 16 | _context = context; 17 | } 18 | 19 | public IList Key { get;set; } 20 | 21 | [TempData] 22 | public string StatusMessage { get; set; } 23 | 24 | public async Task OnGetAsync() 25 | { 26 | Key = await _context.Keys.ToListAsync(); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/ManageNavPages.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Mvc.Rendering; 3 | 4 | namespace Certera.Web.Pages 5 | { 6 | public static class ManageNavPages 7 | { 8 | public static string Tracking => "Tracking"; 9 | 10 | public static string Acme => "Acme"; 11 | 12 | public static string Keys => "Keys"; 13 | 14 | public static string Certificates => "Certificates"; 15 | 16 | public static string Notifications => "Notifications"; 17 | 18 | public static string Settings => "Settings"; 19 | 20 | public static string TrackingNavClass(ViewContext viewContext) => PageNavClass(viewContext, Tracking); 21 | 22 | public static string AcmeNavClass(ViewContext viewContext) => PageNavClass(viewContext, Acme); 23 | 24 | public static string KeysNavClass(ViewContext viewContext) => PageNavClass(viewContext, Keys); 25 | 26 | public static string CertificatesNavClass(ViewContext viewContext) => PageNavClass(viewContext, Certificates); 27 | 28 | public static string NotificationsNavClass(ViewContext viewContext) => PageNavClass(viewContext, Notifications); 29 | 30 | public static string SettingsNavClass(ViewContext viewContext) => PageNavClass(viewContext, Settings); 31 | 32 | private static string PageNavClass(ViewContext viewContext, string page) 33 | { 34 | var activePage = viewContext.ViewData["ActivePage"] as string 35 | ?? System.IO.Path.GetFileNameWithoutExtension(viewContext.ActionDescriptor.DisplayName); 36 | return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? "is-active" : null; 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Notifications/Index.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.AspNetCore.Mvc.RazorPages; 7 | using Microsoft.AspNetCore.Mvc.Rendering; 8 | using Microsoft.EntityFrameworkCore; 9 | using Certera.Data; 10 | using Certera.Data.Models; 11 | using System.Security.Claims; 12 | 13 | namespace Certera.Web.Pages.Notifications 14 | { 15 | public class IndexModel : PageModel 16 | { 17 | private readonly Certera.Data.DataContext _context; 18 | 19 | public IndexModel(Certera.Data.DataContext context) 20 | { 21 | _context = context; 22 | } 23 | 24 | [TempData] 25 | public string StatusMessage { get; set; } 26 | 27 | [BindProperty] 28 | public NotificationSetting NotificationSetting { get; set; } 29 | 30 | public async Task OnGetAsync() 31 | { 32 | var userId = long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)); 33 | 34 | NotificationSetting = await _context.NotificationSettings 35 | .Include(x => x.ApplicationUser) 36 | .FirstOrDefaultAsync(m => m.ApplicationUserId == userId); 37 | 38 | if (NotificationSetting == null) 39 | { 40 | NotificationSetting = new NotificationSetting 41 | { 42 | ApplicationUserId = userId 43 | }; 44 | _context.NotificationSettings.Add(NotificationSetting); 45 | await _context.SaveChangesAsync(); 46 | } 47 | return Page(); 48 | } 49 | 50 | public async Task OnPostAsync() 51 | { 52 | if (!ModelState.IsValid) 53 | { 54 | return Page(); 55 | } 56 | 57 | _context.Attach(NotificationSetting).State = EntityState.Modified; 58 | 59 | var userId = long.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)); 60 | NotificationSetting.ApplicationUserId = userId; 61 | 62 | try 63 | { 64 | await _context.SaveChangesAsync(); 65 | StatusMessage = "Notification settings updated"; 66 | } 67 | catch (DbUpdateConcurrencyException) 68 | { 69 | if (!NotificationSettingExists(NotificationSetting.NotificationSettingId)) 70 | { 71 | return NotFound(); 72 | } 73 | else 74 | { 75 | throw; 76 | } 77 | } 78 | 79 | return RedirectToPage("./Index"); 80 | } 81 | 82 | private bool NotificationSettingExists(long id) 83 | { 84 | return _context.NotificationSettings.Any(e => e.NotificationSettingId == id); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Setup/Acme.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model Certera.Web.Pages.Setup.AcmeModel 3 | 4 | @{ 5 | Layout = "/Pages/Shared/_Layout.cshtml"; 6 | ViewData["Title"] = "Setup"; 7 | } 8 | 9 | @section Head { 10 | 18 | } 19 | 20 |
21 |
22 |
23 |

24 | Setup - Let's Encrypt Account 25 |

26 |
27 |
28 |
29 |
30 | 31 | 32 | 33 | 34 |
35 |
36 | 37 |
38 | (Optional. Upload an existing key or a new one will be created and saved) 39 |
40 | 41 | 42 |
43 |
44 | 48 | 49 |
50 |
51 | 52 |
53 |
54 |
55 |
56 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Setup/Certificate.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model Certera.Web.Pages.Setup.CertificateModel 3 | 4 | @using Microsoft.Extensions.Options 5 | @using Certera.Web.Options 6 | @inject IOptionsSnapshot HttpServerOptions 7 | 8 | @{ 9 | Layout = "/Pages/Shared/_Layout.cshtml"; 10 | ViewData["Title"] = "Setup"; 11 | } 12 | 13 | @section Head { 14 | 19 | } 20 | 21 |
22 |
23 |
24 |

25 | Setup - Certificate 26 |

27 |
28 |

29 | We're now ready to get a certificate for "@HttpServerOptions.Value.SiteHostname". Please make sure 30 | port 80 is open to the internet if you haven't already done that. 31 |
32 |
33 | Alright, let's go! This can take a moment, but we'll keep you updated all of the way through. 34 |

35 |
36 | 37 |
38 |
39 |
40 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Setup/Certificate.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Certera.Web.Options; 2 | using Microsoft.AspNetCore.Mvc.RazorPages; 3 | using Microsoft.Extensions.Options; 4 | 5 | namespace Certera.Web.Pages.Setup 6 | { 7 | public class CertificateModel : PageModel 8 | { 9 | private readonly IOptionsSnapshot _httpServerOptions; 10 | 11 | public string HttpsHost { get; set; } 12 | 13 | public CertificateModel(IOptionsSnapshot httpServerOptions) 14 | { 15 | _httpServerOptions = httpServerOptions; 16 | } 17 | 18 | public void OnGet() 19 | { 20 | HttpsHost = _httpServerOptions.Value.SiteHostname; 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Setup/Finished.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model Certera.Web.Pages.Setup.FinishedModel 3 | 4 | @using Certera.Web.Options 5 | @using Microsoft.Extensions.Options 6 | @inject IOptionsSnapshot HttpServerOptions 7 | @{ 8 | Layout = "/Pages/Shared/_Layout.cshtml"; 9 | ViewData["Title"] = "Setup"; 10 | } 11 | @section Head { 12 | 17 | } 18 | 19 |
20 |
21 |
22 |

23 | Setup - Finished 24 |

25 |
26 |

27 | Congratulations, setup is done! 28 |
29 |
30 | Make sure port @HttpServerOptions.Value.HttpsPort is accessible from the internet and Certera is ready to go. 31 | Next, login to get started. 32 |

33 | 34 |
35 |
36 |
37 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Setup/Finished.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Certera.Web.Options; 2 | using Microsoft.AspNetCore.Mvc.RazorPages; 3 | 4 | namespace Certera.Web.Pages.Setup 5 | { 6 | public class FinishedModel : PageModel 7 | { 8 | private readonly IWritableOptions _setupOptions; 9 | 10 | public FinishedModel(IWritableOptions setupOptions) 11 | { 12 | _setupOptions = setupOptions; 13 | } 14 | 15 | public void OnGet() 16 | { 17 | _setupOptions.Update(x => x.Finished = true); 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Setup/Index.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model Certera.Web.Pages.Setup.IndexModel 3 | 4 | @{ 5 | Layout = "/Pages/Shared/_Layout.cshtml"; 6 | ViewData["Title"] = "Setup"; 7 | } 8 | 9 | @section Head { 10 | 18 | } 19 | 20 |
21 |
22 |
23 |

24 | Setup - Account 25 |

26 |
Just a few things before we start...
27 |
28 |
29 |
30 |
31 | 32 | * 33 | 34 | 35 |
36 |
37 | 38 | 39 | 40 | 41 |
42 |
43 | 44 | 45 | 46 | 47 |
48 |
49 | 50 |
51 |
52 |
53 |
54 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Setup/Server.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model Certera.Web.Pages.Setup.ServerModel 3 | 4 | @{ 5 | Layout = "/Pages/Shared/_Layout.cshtml"; 6 | ViewData["Title"] = "Setup"; 7 | } 8 | 9 | @section Head { 10 | 19 | } 20 | 21 |
22 |
23 |
24 |

25 | Setup - Server 26 |

27 |
28 |
29 |
30 |
31 | 32 | * 33 |
34 | (e.g. cert.mysite.com) 35 |
36 | 37 | 38 |
39 |
40 | 41 | 42 | 43 |
44 | 45 |
46 | 50 |
51 |
52 |
53 | 54 |
55 |
56 |
57 |
58 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Shared/_Layout.cshtml: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | @ViewData["Title"] - Certera 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | @RenderSection("Head", required: false) 17 | 18 | 19 |
20 |
21 | 22 | 23 |
24 |
25 | @RenderBody() 26 | @RenderSection("Scripts", required: false) 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Shared/_LoginPartial.cshtml: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Identity 2 | @inject SignInManager SignInManager 3 | 4 | 23 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Shared/_SmtpOptionsMissing.cshtml: -------------------------------------------------------------------------------- 1 | @using Microsoft.Extensions.Options 2 | @using Certera.Core.Notifications 3 | @inject IOptionsSnapshot MailSenderInfo 4 | 5 | @if (string.IsNullOrWhiteSpace(MailSenderInfo.Value.Host)) 6 | { 7 |
8 | × 9 | Configure SMTP settings in the config.json file 10 |
11 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Shared/_StatusMessage.cshtml: -------------------------------------------------------------------------------- 1 | @model string 2 | 3 | @if (!string.IsNullOrEmpty(Model)) 4 | { 5 |
6 | × 7 | @Model 8 |
9 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Tracking/Delete.cshtml: -------------------------------------------------------------------------------- 1 | @page "{id}" 2 | @model Certera.Web.Pages.Tracking.DeleteModel 3 | 4 | @{ 5 | ViewData["Title"] = "Delete"; 6 | ViewData["ActivePage"] = ManageNavPages.Tracking; 7 | } 8 | 9 |
10 | Certificate - Delete 11 |
12 |
13 | 14 |
15 | List 16 |
17 |
18 | @Html.DisplayNameFor(model => model.DomainCertificate.Subject) 19 |
20 |
21 | @Html.DisplayFor(model => model.DomainCertificate.Subject) 22 |
23 |
24 | @Html.DisplayNameFor(model => model.DomainCertificate.IssuerName) 25 |
26 |
27 | @Html.DisplayFor(model => model.DomainCertificate.IssuerName) 28 |
29 |
30 | @Html.DisplayNameFor(model => model.DomainCertificate.DateCreated) 31 |
32 |
33 | @Model.DomainCertificate.DateCreated.ToLocalTime().ToString() 34 |
35 |
36 | 37 |
38 | 39 | 40 |
41 |
42 |
-------------------------------------------------------------------------------- /src/Certera.Web/Pages/Tracking/Delete.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Certera.Data; 6 | using Certera.Data.Models; 7 | using Microsoft.AspNetCore.Mvc; 8 | using Microsoft.AspNetCore.Mvc.RazorPages; 9 | 10 | namespace Certera.Web.Pages.Tracking 11 | { 12 | public class DeleteModel : PageModel 13 | { 14 | private readonly DataContext _dataContext; 15 | 16 | public DeleteModel(DataContext dataContext) 17 | { 18 | _dataContext = dataContext; 19 | } 20 | 21 | 22 | [BindProperty] 23 | public DomainCertificate DomainCertificate { get; set; } 24 | [TempData] 25 | public string StatusMessage { get; set; } 26 | 27 | public async Task OnGetAsync(long? id) 28 | { 29 | if (id == null) 30 | { 31 | return NotFound(); 32 | } 33 | 34 | DomainCertificate = await _dataContext.DomainCertificates.FindAsync(id); 35 | if (DomainCertificate == null) 36 | { 37 | return NotFound(); 38 | } 39 | 40 | return Page(); 41 | } 42 | 43 | public async Task OnPostAsync(long? id) 44 | { 45 | if (id == null) 46 | { 47 | return NotFound(); 48 | } 49 | 50 | DomainCertificate = await _dataContext.DomainCertificates.FindAsync(id); 51 | if (DomainCertificate == null) 52 | { 53 | return NotFound(); 54 | } 55 | 56 | _dataContext.DomainCertificates.Remove(DomainCertificate); 57 | await _dataContext.SaveChangesAsync(); 58 | 59 | StatusMessage = "Certificate deleted"; 60 | 61 | return RedirectToPage("./Index"); 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Tracking/Edit.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model Certera.Web.Pages.Tracking.EditModel 3 | 4 | @{ 5 | ViewData["Title"] = "Edit"; 6 | ViewData["ActivePage"] = ManageNavPages.Tracking; 7 | } 8 | 9 |
10 | Tracking - Edit 11 |
12 |
13 | 14 |
15 | Back 16 |
17 |
18 |
19 |
20 |
21 | 22 |
23 | Enter domains like [https://]example.com[:port] (port 443 is used by default if no port specified) 24 |
25 | 26 | 27 |
28 |
29 | 30 |
31 | Upload a certificate to track its expiration 32 |
33 | 34 | 35 | 36 |
37 | 38 |
39 |
40 |
41 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Tracking/History.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Certera.Data; 6 | using Certera.Data.Models; 7 | using Microsoft.AspNetCore.Mvc; 8 | using Microsoft.AspNetCore.Mvc.RazorPages; 9 | using Microsoft.EntityFrameworkCore; 10 | 11 | namespace Certera.Web.Pages.Tracking 12 | { 13 | public class HistoryModel : PageModel 14 | { 15 | private readonly DataContext _context; 16 | 17 | public HistoryModel(DataContext context) 18 | { 19 | _context = context; 20 | } 21 | 22 | [TempData] 23 | public string StatusMessage { get; set; } 24 | 25 | public Domain Domain { get; set; } 26 | 27 | public IActionResult OnGet(long? id) 28 | { 29 | if (id == null) 30 | { 31 | return NotFound(); 32 | } 33 | 34 | Domain = _context.Domains 35 | .Include(x => x.DomainScans) 36 | .ThenInclude(x => x.DomainCertificate) 37 | .FirstOrDefault(x => x.DomainId == id.Value); 38 | 39 | if (Domain == null) 40 | { 41 | return NotFound(); 42 | } 43 | return Page(); 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Tracking/Index.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data; 2 | using Certera.Data.Views; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.AspNetCore.Mvc.RazorPages; 5 | using Microsoft.AspNetCore.Mvc.Rendering; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Linq.Expressions; 10 | using System.Threading.Tasks; 11 | 12 | namespace Certera.Web.Pages.Tracking 13 | { 14 | public class IndexModel : PageModel 15 | { 16 | private readonly DataContext _dataContext; 17 | 18 | public IndexModel(DataContext dataContext) 19 | { 20 | _dataContext = dataContext; 21 | } 22 | 23 | public List TrackedCertificates { get; set; } 24 | public List Sort { get; set; } 25 | 26 | [TempData] 27 | public string StatusMessage { get; set; } 28 | 29 | public IActionResult OnGet(string sort) 30 | { 31 | if (!string.IsNullOrWhiteSpace(sort)) 32 | { 33 | sort = sort.ToLower(); 34 | } 35 | 36 | Expression> sortExpression = null; 37 | Expression> sortThenByExpression = x => x.Subject; 38 | 39 | switch (sort) 40 | { 41 | case "expiration": 42 | sortExpression = x => x.DaysRemaining; 43 | break; 44 | case "subject": 45 | sortExpression = x => x.Subject; 46 | break; 47 | case "issuer": 48 | sortExpression = x => x.Issuer; 49 | break; 50 | case "domain": 51 | sortExpression = x => x.RegistrableDomain; 52 | break; 53 | case "source": 54 | sortExpression = x => x.Source; 55 | break; 56 | case "order": 57 | default: 58 | sortExpression = x => x.Order; 59 | sortThenByExpression = x => x.DateModified; 60 | break; 61 | } 62 | 63 | Sort = new List 64 | { 65 | new SelectListItem { Text = "Order", Value = "order", Selected = sort == "order" }, 66 | new SelectListItem { Text = "Expiration", Value = "expiration", Selected = sort == "expiration" }, 67 | new SelectListItem { Text = "Subject", Value = "subject", Selected = sort == "subject" }, 68 | new SelectListItem { Text = "Domain", Value = "domain", Selected = sort == "domain" }, 69 | new SelectListItem { Text = "Issuer", Value = "issuer", Selected = sort == "issuer" }, 70 | new SelectListItem { Text = "Source", Value = "source", Selected = sort == "source" } 71 | }; 72 | 73 | var allTrackedCerts = _dataContext.GetTrackedCertificates(); 74 | 75 | TrackedCertificates = allTrackedCerts 76 | .AsQueryable() 77 | .OrderBy(sortExpression) 78 | .ThenBy(sortThenByExpression) 79 | .ToList(); 80 | 81 | return Page(); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Tracking/Scan.cshtml: -------------------------------------------------------------------------------- 1 | @page "{id?}" 2 | @model Certera.Web.Pages.Tracking.ScanModel 3 | 4 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/Tracking/Scan.cshtml.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data; 2 | using Certera.Web.Services; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.AspNetCore.Mvc.RazorPages; 5 | using Microsoft.EntityFrameworkCore; 6 | using System.Threading.Tasks; 7 | 8 | namespace Certera.Web.Pages.Tracking 9 | { 10 | public class ScanModel : PageModel 11 | { 12 | private readonly DataContext _dataContext; 13 | private readonly DomainScanService _domainScanSvc; 14 | 15 | public ScanModel(DataContext dataContext, DomainScanService domainScanSvc) 16 | { 17 | _dataContext = dataContext; 18 | _domainScanSvc = domainScanSvc; 19 | } 20 | 21 | [TempData] 22 | public string StatusMessage { get; set; } 23 | 24 | public async Task OnGet(long? id = null, string returnUrl = null) 25 | { 26 | if (id != null) 27 | { 28 | var domain = _dataContext.GetDomain(id.Value); 29 | 30 | if (domain == null) 31 | { 32 | StatusMessage = "Domain not found"; 33 | return RedirectToPage("./Index"); 34 | } 35 | 36 | var scan = _domainScanSvc.Scan(domain); 37 | await _dataContext.SaveChangesAsync(); 38 | 39 | StatusMessage = scan.ScanSuccess ? "Domain scanned successfully" : "Domain scan failed"; 40 | } 41 | else 42 | { 43 | // Schedule a run for all domains to be scanned 44 | _domainScanSvc.ScanAll(); 45 | StatusMessage = "Domain scan queued"; 46 | } 47 | returnUrl = returnUrl ?? Url.Page("./Index"); 48 | return new RedirectResult(returnUrl); 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /src/Certera.Web/Pages/_Layout.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | Layout = "/Pages/Shared/_Layout.cshtml"; 3 | } 4 | 5 | @section Head { 6 | @RenderSection("Head", false) 7 | } 8 |
9 | 10 |
11 | @RenderBody() 12 |
13 |
14 | @section Scripts { 15 | @RenderSection("Scripts", required: false) 16 | } 17 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/_ManageNav.cshtml: -------------------------------------------------------------------------------- 1 |  -------------------------------------------------------------------------------- /src/Certera.Web/Pages/_ViewImports.cshtml: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Identity 2 | @using Certera.Web 3 | @using Certera.Data 4 | @using Certera.Core.Extensions 5 | @namespace Certera.Web.Pages 6 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 7 | -------------------------------------------------------------------------------- /src/Certera.Web/Pages/_ViewStart.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | Layout = "_Layout"; 3 | } 4 | -------------------------------------------------------------------------------- /src/Certera.Web/Properties/PublishProfiles/template.pubxml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | FileSystem 9 | FileSystem 10 | Release 11 | Any CPU 12 | 13 | False 14 | False 15 | net5.0 16 | bd161c0f-f65a-4405-b23a-e0e01692464e 17 | true 18 | <_IsPortable>false 19 | True 20 | true 21 | 22 | false 23 | true 24 | embedded 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Certera.Web/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:58386", 7 | "sslPort": 44323 8 | } 9 | }, 10 | "profiles": { 11 | "Certera.Web": { 12 | "commandName": "Project", 13 | "launchBrowser": false, 14 | "applicationUrl": "http://localhost:80", 15 | "environmentVariables": { 16 | "COMPLUS_ForceENC = 1": "1", 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /src/Certera.Web/Services/BackgroundTaskQueue.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace Certera.Web.Services 7 | { 8 | public interface IBackgroundTaskQueue 9 | { 10 | void QueueBackgroundWorkItem(Func workItem); 11 | 12 | Task> DequeueAsync( 13 | CancellationToken cancellationToken); 14 | } 15 | 16 | public class BackgroundTaskQueue : IBackgroundTaskQueue, IDisposable 17 | { 18 | private ConcurrentQueue> _workItems = 19 | new ConcurrentQueue>(); 20 | private SemaphoreSlim _signal = new SemaphoreSlim(0); 21 | 22 | public void QueueBackgroundWorkItem( 23 | Func workItem) 24 | { 25 | if (workItem == null) 26 | { 27 | throw new ArgumentNullException(nameof(workItem)); 28 | } 29 | 30 | _workItems.Enqueue(workItem); 31 | _signal.Release(); 32 | } 33 | 34 | public async Task> DequeueAsync( 35 | CancellationToken cancellationToken) 36 | { 37 | await _signal.WaitAsync(cancellationToken); 38 | _workItems.TryDequeue(out var workItem); 39 | 40 | return workItem; 41 | } 42 | 43 | #region IDisposable Support 44 | private bool disposedValue = false; // To detect redundant calls 45 | 46 | protected virtual void Dispose(bool disposing) 47 | { 48 | if (!disposedValue) 49 | { 50 | if (disposing) 51 | { 52 | _signal.Dispose(); 53 | } 54 | 55 | disposedValue = true; 56 | } 57 | } 58 | 59 | [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA1816:Dispose methods should call SuppressFinalize", Justification = "No unmanaged resources")] 60 | public void Dispose() 61 | { 62 | Dispose(true); 63 | } 64 | #endregion 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Certera.Web/Services/HostedServices/QueuedHostedService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Hosting; 2 | using Microsoft.Extensions.Logging; 3 | using System; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace Certera.Web.Services.HostedServices 8 | { 9 | public class QueuedHostedService : BackgroundService 10 | { 11 | private readonly ILogger _logger; 12 | 13 | public IBackgroundTaskQueue TaskQueue { get; } 14 | 15 | public QueuedHostedService(IBackgroundTaskQueue taskQueue, 16 | ILogger logger) 17 | { 18 | TaskQueue = taskQueue; 19 | _logger = logger; 20 | } 21 | 22 | protected async override Task ExecuteAsync(CancellationToken cancellationToken) 23 | { 24 | _logger.LogInformation("Queued Hosted Service is starting."); 25 | 26 | while (!cancellationToken.IsCancellationRequested) 27 | { 28 | Func workItem; 29 | try 30 | { 31 | workItem = await TaskQueue.DequeueAsync(cancellationToken); 32 | } 33 | catch (OperationCanceledException) 34 | { 35 | continue; 36 | } 37 | catch (Exception e) 38 | { 39 | _logger.LogError(e, "Unknown error getting work item."); 40 | continue; 41 | } 42 | 43 | try 44 | { 45 | await workItem(cancellationToken); 46 | } 47 | catch (Exception e) 48 | { 49 | _logger.LogError(e, $"Error occurred executing {nameof(workItem)}."); 50 | } 51 | } 52 | 53 | _logger.LogInformation("Queued Hosted Service is stopping."); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Certera.Web/Services/KeyGenerator.cs: -------------------------------------------------------------------------------- 1 | using Certera.Data; 2 | using Certera.Data.Models; 3 | using Certera.Web.AcmeProviders; 4 | using Certes; 5 | using System; 6 | using System.Linq; 7 | 8 | namespace Certera.Web.Services 9 | { 10 | public class KeyGenerator 11 | { 12 | private readonly DataContext _dataContext; 13 | private readonly CertesAcmeProvider _certesAcmeProvider; 14 | 15 | public KeyGenerator(DataContext dataContext, CertesAcmeProvider certesAcmeProvider) 16 | { 17 | _dataContext = dataContext; 18 | _certesAcmeProvider = certesAcmeProvider; 19 | } 20 | 21 | public Key Generate(string name, KeyAlgorithm keyAlgorithm = KeyAlgorithm.RS256, 22 | string description = null, string keyContents = null) 23 | { 24 | if (_dataContext.Keys.Any(x => x.Name == name)) 25 | { 26 | name = $"{name}-{DateTime.Now.ToString("yyyyMMddHHmmss")}"; 27 | } 28 | 29 | if (keyContents == null) 30 | { 31 | keyContents = _certesAcmeProvider.NewKey(keyAlgorithm); 32 | } 33 | 34 | var key = new Key 35 | { 36 | DateCreated = DateTime.UtcNow, 37 | Name = name, 38 | Description = description, 39 | RawData = keyContents, 40 | ApiKey1 = ApiKeyGenerator.CreateApiKey(), 41 | ApiKey2 = ApiKeyGenerator.CreateApiKey() 42 | }; 43 | 44 | _dataContext.Keys.Add(key); 45 | _dataContext.SaveChanges(); 46 | 47 | return key; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Certera.Web/Services/ProcessRunner.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Text; 6 | 7 | namespace Certera.Web.Services 8 | { 9 | public class ProcessRunner 10 | { 11 | private const int PROCESS_WAIT_MS = 60000; 12 | 13 | public (int ExitCode, string Output) Run(string executablePath, string arguments, IDictionary environmentVariables = default) 14 | { 15 | var output = new StringBuilder(); 16 | var process = new Process() 17 | { 18 | StartInfo = new ProcessStartInfo 19 | { 20 | FileName = executablePath, 21 | Arguments = arguments, 22 | RedirectStandardOutput = true, 23 | RedirectStandardError = true, 24 | UseShellExecute = false, 25 | CreateNoWindow = true, 26 | } 27 | }; 28 | 29 | foreach (var item in environmentVariables ?? Enumerable.Empty>()) 30 | { 31 | process.StartInfo.Environment.Add(item.Key.Trim(), item.Value.Trim()); 32 | } 33 | 34 | using (process) 35 | { 36 | process.Start(); 37 | process.OutputDataReceived += (sender, outputLine) => { if (outputLine.Data != null) output.AppendLine(outputLine.Data); }; 38 | process.ErrorDataReceived += (sender, errorLine) => { if (errorLine.Data != null) output.AppendLine(errorLine.Data); }; 39 | process.BeginErrorReadLine(); 40 | process.BeginOutputReadLine(); 41 | 42 | var exited = process.WaitForExit(PROCESS_WAIT_MS); 43 | if (!exited) 44 | { 45 | process.Kill(true); 46 | } 47 | return (ExitCode: process.ExitCode, Output: output.ToString()); 48 | } 49 | 50 | } 51 | } 52 | } 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/Certera.Web/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "Debug": {}, 4 | "Console": { 5 | "LogLevel": { 6 | "Default": "Debug", 7 | "Microsoft": "Warning" 8 | } 9 | }, 10 | "LogLevel": { 11 | "Default": "Debug", 12 | "System": "Information", 13 | "Microsoft": "Information" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Certera.Web/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "DefaultConnection": "Data Source=Certera.db" 4 | }, 5 | "Logging": { 6 | "LogLevel": { 7 | "Default": "Information", 8 | "Microsoft": "Warning" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Certera.Web/bundleconfig.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "outputFileName": "wwwroot/css/site.min.css", 4 | "inputFiles": [ 5 | "wwwroot/css/normalize.css", 6 | "wwwroot/css/milligram.css", 7 | "wwwroot/css/icons.css", 8 | "wwwroot/css/main.css" 9 | ], 10 | "minify": { 11 | "enabled": true 12 | } 13 | } 14 | ] -------------------------------------------------------------------------------- /src/Certera.Web/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "AllowedRemoteIPAddresses": { 3 | "AdminUI": "", 4 | "API": "" 5 | }, 6 | "SMTP": { 7 | "Host": "", 8 | "Port": 25, 9 | "Username": "", 10 | "Password": "", 11 | "UseSSL": true, 12 | "FromName": "MySite Certs", 13 | "FromEmail": "noreply@mysite.com" 14 | }, 15 | "DNSServers": { 16 | "IPs": [ "1.1.1.1", "8.8.8.8", "4.4.4.4" ] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Certera.Web/wwwroot/css/fonts/icomoon.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/certera-io/certera/578dc7a1a4d1da4094c3b0a97c644ef928fe1fc2/src/Certera.Web/wwwroot/css/fonts/icomoon.eot -------------------------------------------------------------------------------- /src/Certera.Web/wwwroot/css/fonts/icomoon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/certera-io/certera/578dc7a1a4d1da4094c3b0a97c644ef928fe1fc2/src/Certera.Web/wwwroot/css/fonts/icomoon.ttf -------------------------------------------------------------------------------- /src/Certera.Web/wwwroot/css/fonts/icomoon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/certera-io/certera/578dc7a1a4d1da4094c3b0a97c644ef928fe1fc2/src/Certera.Web/wwwroot/css/fonts/icomoon.woff -------------------------------------------------------------------------------- /src/Certera.Web/wwwroot/css/icons.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'icomoon'; 3 | src: url('fonts/icomoon.eot?sdsjaz'); 4 | src: url('fonts/icomoon.eot?sdsjaz#iefix') format('embedded-opentype'), url('fonts/icomoon.ttf?sdsjaz') format('truetype'), url('fonts/icomoon.woff?sdsjaz') format('woff'), url('fonts/icomoon.svg?sdsjaz#icomoon') format('svg'); 5 | font-weight: normal; 6 | font-style: normal; 7 | font-display: block; 8 | } 9 | 10 | [class^="icon-"], [class*=" icon-"] { 11 | /* use !important to prevent issues with browser extensions that change fonts */ 12 | font-family: 'icomoon' !important; 13 | speak: none; 14 | font-style: normal; 15 | font-weight: normal; 16 | font-variant: normal; 17 | text-transform: none; 18 | line-height: 1; 19 | /* Better Font Rendering =========== */ 20 | -webkit-font-smoothing: antialiased; 21 | -moz-osx-font-smoothing: grayscale; 22 | } 23 | 24 | .icon-certificate:before { 25 | content: "\e900"; 26 | } 27 | 28 | .icon-lock:before { 29 | content: "\e901"; 30 | } 31 | 32 | .icon-folder-upload:before { 33 | content: "\e934"; 34 | } 35 | 36 | .icon-alarm:before { 37 | content: "\e950"; 38 | } 39 | 40 | .icon-keyboard:before { 41 | content: "\e955"; 42 | } 43 | 44 | .icon-mobile:before { 45 | content: "\e958"; 46 | } 47 | 48 | .icon-user:before { 49 | content: "\e971"; 50 | } 51 | 52 | .icon-key:before { 53 | content: "\e98d"; 54 | } 55 | 56 | .icon-cog:before { 57 | content: "\e994"; 58 | } 59 | 60 | .icon-bin:before { 61 | content: "\e9ac"; 62 | } 63 | 64 | .icon-earth:before { 65 | content: "\e9ca"; 66 | } 67 | 68 | .icon-warning:before { 69 | content: "\ea07"; 70 | } 71 | 72 | .icon-question:before { 73 | content: "\ea09"; 74 | } 75 | -------------------------------------------------------------------------------- /src/Certera.Web/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/certera-io/certera/578dc7a1a4d1da4094c3b0a97c644ef928fe1fc2/src/Certera.Web/wwwroot/favicon.ico -------------------------------------------------------------------------------- /src/Certera.Web/wwwroot/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /src/Certera.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.29123.88 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Certera.Data", "Certera.Data\Certera.Data.csproj", "{C36B030D-4F6E-4069-979C-8D56503A63CE}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Certera.Web", "Certera.Web\Certera.Web.csproj", "{BD161C0F-F65A-4405-B23A-E0E01692464E}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Certera.Core", "Certera.Core\Certera.Core.csproj", "{DBD869CA-5729-48C3-B88F-9532C50D0D0C}" 11 | EndProject 12 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Config", "Config", "{73372307-04C0-46F7-9512-A70083492090}" 13 | ProjectSection(SolutionItems) = preProject 14 | .editorconfig = .editorconfig 15 | EndProjectSection 16 | EndProject 17 | Global 18 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 19 | Debug|Any CPU = Debug|Any CPU 20 | Release|Any CPU = Release|Any CPU 21 | EndGlobalSection 22 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 23 | {C36B030D-4F6E-4069-979C-8D56503A63CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {C36B030D-4F6E-4069-979C-8D56503A63CE}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {C36B030D-4F6E-4069-979C-8D56503A63CE}.Release|Any CPU.ActiveCfg = Release|Any CPU 26 | {C36B030D-4F6E-4069-979C-8D56503A63CE}.Release|Any CPU.Build.0 = Release|Any CPU 27 | {BD161C0F-F65A-4405-B23A-E0E01692464E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 28 | {BD161C0F-F65A-4405-B23A-E0E01692464E}.Debug|Any CPU.Build.0 = Debug|Any CPU 29 | {BD161C0F-F65A-4405-B23A-E0E01692464E}.Release|Any CPU.ActiveCfg = Release|Any CPU 30 | {BD161C0F-F65A-4405-B23A-E0E01692464E}.Release|Any CPU.Build.0 = Release|Any CPU 31 | {DBD869CA-5729-48C3-B88F-9532C50D0D0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 32 | {DBD869CA-5729-48C3-B88F-9532C50D0D0C}.Debug|Any CPU.Build.0 = Debug|Any CPU 33 | {DBD869CA-5729-48C3-B88F-9532C50D0D0C}.Release|Any CPU.ActiveCfg = Release|Any CPU 34 | {DBD869CA-5729-48C3-B88F-9532C50D0D0C}.Release|Any CPU.Build.0 = Release|Any CPU 35 | EndGlobalSection 36 | GlobalSection(SolutionProperties) = preSolution 37 | HideSolutionNode = FALSE 38 | EndGlobalSection 39 | GlobalSection(ExtensibilityGlobals) = postSolution 40 | SolutionGuid = {56AF993B-2E3F-4E61-B311-D72E1FE10B77} 41 | EndGlobalSection 42 | EndGlobal 43 | --------------------------------------------------------------------------------