diff --git a/.appdata/.dotnet/sdk-advertising/10.0.300/microsoft.net.workloads/10.0.300.3/microsoft.net.workloads.workloadset.json b/.appdata/.dotnet/sdk-advertising/10.0.300/microsoft.net.workloads/10.0.300.3/microsoft.net.workloads.workloadset.json new file mode 100644 index 0000000..fb6c3fe --- /dev/null +++ b/.appdata/.dotnet/sdk-advertising/10.0.300/microsoft.net.workloads/10.0.300.3/microsoft.net.workloads.workloadset.json @@ -0,0 +1,18 @@ +{ + "Microsoft.NET.Workload.Emscripten.Current": "10.0.108/10.0.100", + "Microsoft.NET.Workload.Emscripten.net6": "10.0.108/10.0.100", + "Microsoft.NET.Workload.Emscripten.net7": "10.0.108/10.0.100", + "Microsoft.NET.Workload.Emscripten.net8": "10.0.108/10.0.100", + "Microsoft.NET.Workload.Emscripten.net9": "10.0.108/10.0.100", + "Microsoft.NET.Sdk.Android": "36.1.53/10.0.100", + "Microsoft.NET.Sdk.iOS": "26.5.10284/10.0.100", + "Microsoft.NET.Sdk.MacCatalyst": "26.5.10284/10.0.100", + "Microsoft.NET.Sdk.macOS": "26.5.10284/10.0.100", + "Microsoft.NET.Sdk.Maui": "10.0.20/10.0.100", + "Microsoft.NET.Sdk.tvOS": "26.5.10284/10.0.100", + "Microsoft.NET.Workload.Mono.ToolChain.Current": "10.0.108/10.0.100", + "Microsoft.NET.Workload.Mono.ToolChain.net6": "10.0.108/10.0.100", + "Microsoft.NET.Workload.Mono.ToolChain.net7": "10.0.108/10.0.100", + "Microsoft.NET.Workload.Mono.ToolChain.net8": "10.0.108/10.0.100", + "Microsoft.NET.Workload.Mono.ToolChain.net9": "10.0.108/10.0.100" +} diff --git a/.appdata/.dotnet/sdk-advertising/10.0.300/microsoft.net.workloads/AdvertisedManifestFeatureBand.txt b/.appdata/.dotnet/sdk-advertising/10.0.300/microsoft.net.workloads/AdvertisedManifestFeatureBand.txt new file mode 100644 index 0000000..d43750c --- /dev/null +++ b/.appdata/.dotnet/sdk-advertising/10.0.300/microsoft.net.workloads/AdvertisedManifestFeatureBand.txt @@ -0,0 +1 @@ +10.0.300 \ No newline at end of file diff --git a/.appdata/.dotnet/sdk-advertising/10.0.300/microsoft.net.workloads/workloadVersion.txt b/.appdata/.dotnet/sdk-advertising/10.0.300/microsoft.net.workloads/workloadVersion.txt new file mode 100644 index 0000000..5558114 --- /dev/null +++ b/.appdata/.dotnet/sdk-advertising/10.0.300/microsoft.net.workloads/workloadVersion.txt @@ -0,0 +1 @@ +10.0.300.3 \ No newline at end of file diff --git a/.appdata/runner-appdata/NuGet/NuGet.Config b/.appdata/runner-appdata/NuGet/NuGet.Config new file mode 100644 index 0000000..3f0e003 --- /dev/null +++ b/.appdata/runner-appdata/NuGet/NuGet.Config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.appdata/runner-localappdata/NuGet/v3-cache/670c1461c29885f9aa22c281d8b7da90845b38e4$ps_api.nuget.org_v3_index.json/repository_signatures_5.0.0.dat b/.appdata/runner-localappdata/NuGet/v3-cache/670c1461c29885f9aa22c281d8b7da90845b38e4$ps_api.nuget.org_v3_index.json/repository_signatures_5.0.0.dat new file mode 100644 index 0000000..b80fc8d --- /dev/null +++ b/.appdata/runner-localappdata/NuGet/v3-cache/670c1461c29885f9aa22c281d8b7da90845b38e4$ps_api.nuget.org_v3_index.json/repository_signatures_5.0.0.dat @@ -0,0 +1,35 @@ +{ + "allRepositorySigned": true, + "signingCertificates": [ + { + "fingerprints": { + "2.16.840.1.101.3.4.2.1": "0e5f38f57dc1bcc806d8494f4f90fbcedd988b46760709cbeec6f4219aa6157d" + }, + "subject": "CN=NuGet.org Repository by Microsoft, O=NuGet.org Repository by Microsoft, L=Redmond, S=Washington, C=US", + "issuer": "CN=DigiCert SHA2 Assured ID Code Signing CA, OU=www.digicert.com, O=DigiCert Inc, C=US", + "notBefore": "2018-04-10T00:00:00.0000000Z", + "notAfter": "2021-04-14T12:00:00.0000000Z", + "contentUrl": "https://api.nuget.org/v3-index/repository-signatures/certificates/0e5f38f57dc1bcc806d8494f4f90fbcedd988b46760709cbeec6f4219aa6157d.crt" + }, + { + "fingerprints": { + "2.16.840.1.101.3.4.2.1": "5a2901d6ada3d18260b9c6dfe2133c95d74b9eef6ae0e5dc334c8454d1477df4" + }, + "subject": "CN=NuGet.org Repository by Microsoft, O=NuGet.org Repository by Microsoft, L=Redmond, S=Washington, C=US", + "issuer": "CN=DigiCert SHA2 Assured ID Code Signing CA, OU=www.digicert.com, O=DigiCert Inc, C=US", + "notBefore": "2021-02-16T00:00:00.0000000Z", + "notAfter": "2024-05-15T23:59:59.0000000Z", + "contentUrl": "https://api.nuget.org/v3-index/repository-signatures/certificates/5a2901d6ada3d18260b9c6dfe2133c95d74b9eef6ae0e5dc334c8454d1477df4.crt" + }, + { + "fingerprints": { + "2.16.840.1.101.3.4.2.1": "1f4b311d9acc115c8dc8018b5a49e00fce6da8e2855f9f014ca6f34570bc482d" + }, + "subject": "CN=NuGet.org Repository by Microsoft, O=NuGet.org Repository by Microsoft, L=Redmond, S=Washington, C=US", + "issuer": "CN=DigiCert Trusted G4 Code Signing RSA4096 SHA384 2021 CA1, O=\"DigiCert, Inc.\", C=US", + "notBefore": "2024-02-23T00:00:00.0000000Z", + "notAfter": "2027-05-18T23:59:59.0000000Z", + "contentUrl": "https://api.nuget.org/v3-index/repository-signatures/certificates/1f4b311d9acc115c8dc8018b5a49e00fce6da8e2855f9f014ca6f34570bc482d.crt" + } + ] +} \ No newline at end of file diff --git a/.appdata/runner-localappdata/NuGet/v3-cache/670c1461c29885f9aa22c281d8b7da90845b38e4$ps_api.nuget.org_v3_index.json/service_index.dat b/.appdata/runner-localappdata/NuGet/v3-cache/670c1461c29885f9aa22c281d8b7da90845b38e4$ps_api.nuget.org_v3_index.json/service_index.dat new file mode 100644 index 0000000..a48b3bb --- /dev/null +++ b/.appdata/runner-localappdata/NuGet/v3-cache/670c1461c29885f9aa22c281d8b7da90845b38e4$ps_api.nuget.org_v3_index.json/service_index.dat @@ -0,0 +1,207 @@ +{ + "version": "3.0.0", + "resources": [ + { + "@id": "https://azuresearch-usnc.nuget.org/query", + "@type": "SearchQueryService", + "comment": "Query endpoint of NuGet Search service (primary)" + }, + { + "@id": "https://azuresearch-ussc.nuget.org/query", + "@type": "SearchQueryService", + "comment": "Query endpoint of NuGet Search service (secondary)" + }, + { + "@id": "https://azuresearch-usnc.nuget.org/autocomplete", + "@type": "SearchAutocompleteService", + "comment": "Autocomplete endpoint of NuGet Search service (primary)" + }, + { + "@id": "https://azuresearch-ussc.nuget.org/autocomplete", + "@type": "SearchAutocompleteService", + "comment": "Autocomplete endpoint of NuGet Search service (secondary)" + }, + { + "@id": "https://azuresearch-usnc.nuget.org/", + "@type": "SearchGalleryQueryService/3.0.0-rc", + "comment": "Azure Website based Search Service used by Gallery (primary)" + }, + { + "@id": "https://azuresearch-ussc.nuget.org/", + "@type": "SearchGalleryQueryService/3.0.0-rc", + "comment": "Azure Website based Search Service used by Gallery (secondary)" + }, + { + "@id": "https://api.nuget.org/v3/registration5-semver1/", + "@type": "RegistrationsBaseUrl", + "comment": "Base URL of Azure storage where NuGet package registration info is stored" + }, + { + "@id": "https://api.nuget.org/v3-flatcontainer/", + "@type": "PackageBaseAddress/3.0.0", + "comment": "Base URL of where NuGet packages are stored, in the format https://api.nuget.org/v3-flatcontainer/{id-lower}/{version-lower}/{id-lower}.{version-lower}.nupkg" + }, + { + "@id": "https://www.nuget.org/api/v2", + "@type": "LegacyGallery" + }, + { + "@id": "https://www.nuget.org/api/v2", + "@type": "LegacyGallery/2.0.0" + }, + { + "@id": "https://www.nuget.org/api/v2/package", + "@type": "PackagePublish/2.0.0" + }, + { + "@id": "https://www.nuget.org/api/v2/symbolpackage", + "@type": "SymbolPackagePublish/4.9.0", + "comment": "The gallery symbol publish endpoint." + }, + { + "@id": "https://azuresearch-usnc.nuget.org/query", + "@type": "SearchQueryService/3.0.0-rc", + "comment": "Query endpoint of NuGet Search service (primary) used by RC clients" + }, + { + "@id": "https://azuresearch-ussc.nuget.org/query", + "@type": "SearchQueryService/3.0.0-rc", + "comment": "Query endpoint of NuGet Search service (secondary) used by RC clients" + }, + { + "@id": "https://azuresearch-usnc.nuget.org/query", + "@type": "SearchQueryService/3.5.0", + "comment": "Query endpoint of NuGet Search service (primary) that supports package type filtering" + }, + { + "@id": "https://azuresearch-ussc.nuget.org/query", + "@type": "SearchQueryService/3.5.0", + "comment": "Query endpoint of NuGet Search service (secondary) that supports package type filtering" + }, + { + "@id": "https://azuresearch-usnc.nuget.org/autocomplete", + "@type": "SearchAutocompleteService/3.0.0-rc", + "comment": "Autocomplete endpoint of NuGet Search service (primary) used by RC clients" + }, + { + "@id": "https://azuresearch-ussc.nuget.org/autocomplete", + "@type": "SearchAutocompleteService/3.0.0-rc", + "comment": "Autocomplete endpoint of NuGet Search service (secondary) used by RC clients" + }, + { + "@id": "https://azuresearch-usnc.nuget.org/autocomplete", + "@type": "SearchAutocompleteService/3.5.0", + "comment": "Autocomplete endpoint of NuGet Search service (primary) that supports package type filtering" + }, + { + "@id": "https://azuresearch-ussc.nuget.org/autocomplete", + "@type": "SearchAutocompleteService/3.5.0", + "comment": "Autocomplete endpoint of NuGet Search service (secondary) that supports package type filtering" + }, + { + "@id": "https://api.nuget.org/v3/registration5-semver1/", + "@type": "RegistrationsBaseUrl/3.0.0-rc", + "comment": "Base URL of Azure storage where NuGet package registration info is stored used by RC clients. This base URL does not include SemVer 2.0.0 packages." + }, + { + "@id": "https://www.nuget.org/packages/{id}/{version}/ReportAbuse", + "@type": "ReportAbuseUriTemplate/3.0.0-rc", + "comment": "URI template used by NuGet Client to construct Report Abuse URL for packages used by RC clients" + }, + { + "@id": "https://api.nuget.org/v3/registration5-semver1/{id-lower}/index.json", + "@type": "PackageDisplayMetadataUriTemplate/3.0.0-rc", + "comment": "URI template used by NuGet Client to construct display metadata for Packages using ID" + }, + { + "@id": "https://api.nuget.org/v3/registration5-semver1/{id-lower}/{version-lower}.json", + "@type": "PackageVersionDisplayMetadataUriTemplate/3.0.0-rc", + "comment": "URI template used by NuGet Client to construct display metadata for Packages using ID, Version" + }, + { + "@id": "https://azuresearch-usnc.nuget.org/query", + "@type": "SearchQueryService/3.0.0-beta", + "comment": "Query endpoint of NuGet Search service (primary) used by beta clients" + }, + { + "@id": "https://azuresearch-ussc.nuget.org/query", + "@type": "SearchQueryService/3.0.0-beta", + "comment": "Query endpoint of NuGet Search service (secondary) used by beta clients" + }, + { + "@id": "https://azuresearch-usnc.nuget.org/autocomplete", + "@type": "SearchAutocompleteService/3.0.0-beta", + "comment": "Autocomplete endpoint of NuGet Search service (primary) used by beta clients" + }, + { + "@id": "https://azuresearch-ussc.nuget.org/autocomplete", + "@type": "SearchAutocompleteService/3.0.0-beta", + "comment": "Autocomplete endpoint of NuGet Search service (secondary) used by beta clients" + }, + { + "@id": "https://api.nuget.org/v3/registration5-semver1/", + "@type": "RegistrationsBaseUrl/3.0.0-beta", + "comment": "Base URL of Azure storage where NuGet package registration info is stored used by Beta clients. This base URL does not include SemVer 2.0.0 packages." + }, + { + "@id": "https://www.nuget.org/packages/{id}/{version}/ReportAbuse", + "@type": "ReportAbuseUriTemplate/3.0.0-beta", + "comment": "URI template used by NuGet Client to construct Report Abuse URL for packages" + }, + { + "@id": "https://www.nuget.org/packages/{id}/{version}?_src=template", + "@type": "PackageDetailsUriTemplate/5.1.0", + "comment": "URI template used by NuGet Client to construct details URL for packages" + }, + { + "@id": "https://www.nuget.org/profiles/{owner}?_src=template", + "@type": "OwnerDetailsUriTemplate/6.11.0", + "comment": "URI template used by NuGet Client to construct owner URL for packages" + }, + { + "@id": "https://api.nuget.org/v3/registration5-gz-semver1/", + "@type": "RegistrationsBaseUrl/3.4.0", + "comment": "Base URL of Azure storage where NuGet package registration info is stored in GZIP format. This base URL does not include SemVer 2.0.0 packages." + }, + { + "@id": "https://api.nuget.org/v3/registration5-gz-semver2/", + "@type": "RegistrationsBaseUrl/3.6.0", + "comment": "Base URL of Azure storage where NuGet package registration info is stored in GZIP format. This base URL includes SemVer 2.0.0 packages." + }, + { + "@id": "https://api.nuget.org/v3/registration5-gz-semver2/", + "@type": "RegistrationsBaseUrl/Versioned", + "clientVersion": "4.3.0-alpha", + "comment": "Base URL of Azure storage where NuGet package registration info is stored in GZIP format. This base URL includes SemVer 2.0.0 packages." + }, + { + "@id": "https://api.nuget.org/v3-index/repository-signatures/4.7.0/index.json", + "@type": "RepositorySignatures/4.7.0", + "comment": "The endpoint for discovering information about this package source's repository signatures." + }, + { + "@id": "https://api.nuget.org/v3-index/repository-signatures/5.0.0/index.json", + "@type": "RepositorySignatures/5.0.0", + "comment": "The endpoint for discovering information about this package source's repository signatures." + }, + { + "@id": "https://api.nuget.org/v3/vulnerabilities/index.json", + "@type": "VulnerabilityInfo/6.7.0", + "comment": "The endpoint for discovering information about vulnerabilities of packages in this package source." + }, + { + "@id": "https://api.nuget.org/v3/catalog0/index.json", + "@type": "Catalog/3.0.0", + "comment": "Index of the NuGet package catalog." + }, + { + "@id": "https://api.nuget.org/v3-flatcontainer/{lower_id}/{lower_version}/readme", + "@type": "ReadmeUriTemplate/6.13.0", + "comment": "URI template used by NuGet Client to construct a URL for downloading a package's README." + } + ], + "@context": { + "@vocab": "http://schema.nuget.org/services#", + "comment": "http://www.w3.org/2000/01/rdf-schema#comment" + } +} \ No newline at end of file diff --git a/PlotLine/Data/AuthRepositories.cs b/PlotLine/Data/AuthRepositories.cs new file mode 100644 index 0000000..94bef7a --- /dev/null +++ b/PlotLine/Data/AuthRepositories.cs @@ -0,0 +1,187 @@ +using System.Data; +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface IUserRepository +{ + Task GetByEmailAsync(string email); + Task GetByIdAsync(int userId); + Task CreateAsync(string email, string displayName, string passwordHash); + Task UpdateLoginSuccessAsync(int userId); + Task UpdateFailedLoginAsync(int userId, DateTime? lockoutEndUtc); + Task ConfirmEmailAsync(int userId, Guid token); + Task ResetPasswordAsync(int userId, Guid token, string passwordHash); + Task EnableTwoFactorAsync(int userId); + Task DisableTwoFactorAsync(int userId); +} + +public interface IAuthenticationRepository +{ + Task CreateEmailVerificationTokenAsync(int userId, Guid token, DateTime expiryUtc); + Task GetEmailVerificationTokenAsync(Guid token); + Task CreatePasswordResetTokenAsync(int userId, Guid token, DateTime expiryUtc); + Task GetPasswordResetTokenAsync(Guid token); + Task GetTwoFactorAsync(int userId); + Task SaveTwoFactorAsync(int userId, string secretKey, string? recoveryCodes); + Task InsertLoginAuditAsync(UserLoginAudit audit); +} + +public sealed class UserRepository(ISqlConnectionFactory connectionFactory) : IUserRepository +{ + public async Task GetByEmailAsync(string email) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.User_GetByEmail", + new { Email = email }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetByIdAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.User_GetById", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task CreateAsync(string email, string displayName, string passwordHash) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.User_Create", + new { Email = email, DisplayName = displayName, PasswordHash = passwordHash }, + commandType: CommandType.StoredProcedure); + } + + public async Task UpdateLoginSuccessAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.User_UpdateLoginSuccess", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task UpdateFailedLoginAsync(int userId, DateTime? lockoutEndUtc) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.User_UpdateFailedLogin", + new { UserID = userId, LockoutEndUtc = lockoutEndUtc }, + commandType: CommandType.StoredProcedure); + } + + public async Task ConfirmEmailAsync(int userId, Guid token) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.User_ConfirmEmail", + new { UserID = userId, Token = token }, + commandType: CommandType.StoredProcedure); + } + + public async Task ResetPasswordAsync(int userId, Guid token, string passwordHash) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.User_ResetPassword", + new { UserID = userId, Token = token, PasswordHash = passwordHash }, + commandType: CommandType.StoredProcedure); + } + + public async Task EnableTwoFactorAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.User_EnableTwoFactor", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task DisableTwoFactorAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.User_DisableTwoFactor", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } +} + +public sealed class AuthenticationRepository(ISqlConnectionFactory connectionFactory) : IAuthenticationRepository +{ + public async Task CreateEmailVerificationTokenAsync(int userId, Guid token, DateTime expiryUtc) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.User_CreateEmailVerificationToken", + new { UserID = userId, Token = token, ExpiryUtc = expiryUtc }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetEmailVerificationTokenAsync(Guid token) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.User_GetEmailVerificationToken", + new { Token = token }, + commandType: CommandType.StoredProcedure); + } + + public async Task CreatePasswordResetTokenAsync(int userId, Guid token, DateTime expiryUtc) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.User_CreatePasswordResetToken", + new { UserID = userId, Token = token, ExpiryUtc = expiryUtc }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetPasswordResetTokenAsync(Guid token) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.User_GetPasswordResetToken", + new { Token = token }, + commandType: CommandType.StoredProcedure); + } + + public async Task GetTwoFactorAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.User_GetTwoFactor", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } + + public async Task SaveTwoFactorAsync(int userId, string secretKey, string? recoveryCodes) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.User_SaveTwoFactor", + new { UserID = userId, SecretKey = secretKey, RecoveryCodes = recoveryCodes }, + commandType: CommandType.StoredProcedure); + } + + public async Task InsertLoginAuditAsync(UserLoginAudit audit) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.User_InsertLoginAudit", + new + { + audit.UserID, + audit.EmailAttempted, + audit.IpAddress, + audit.UserAgent, + audit.WasSuccessful, + audit.Reason + }, + commandType: CommandType.StoredProcedure); + } +} diff --git a/PlotLine/Models/AuthModels.cs b/PlotLine/Models/AuthModels.cs new file mode 100644 index 0000000..a766b08 --- /dev/null +++ b/PlotLine/Models/AuthModels.cs @@ -0,0 +1,55 @@ +namespace PlotLine.Models; + +public sealed class AppUser +{ + public int UserID { get; set; } + public string Email { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; + public bool EmailConfirmed { get; set; } + public bool IsLocked { get; set; } + public int FailedLoginAttempts { get; set; } + public DateTime? LockoutEndUtc { get; set; } + public bool TwoFactorEnabled { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime UpdatedUtc { get; set; } + public DateTime? LastLoginUtc { get; set; } +} + +public sealed class UserEmailVerificationToken +{ + public int TokenID { get; set; } + public int UserID { get; set; } + public Guid Token { get; set; } + public DateTime ExpiryUtc { get; set; } + public DateTime? UsedUtc { get; set; } +} + +public sealed class UserPasswordResetToken +{ + public int TokenID { get; set; } + public int UserID { get; set; } + public Guid Token { get; set; } + public DateTime ExpiryUtc { get; set; } + public DateTime? UsedUtc { get; set; } +} + +public sealed class UserTwoFactor +{ + public int UserID { get; set; } + public string SecretKey { get; set; } = string.Empty; + public string? RecoveryCodes { get; set; } + public DateTime CreatedUtc { get; set; } +} + +public sealed class UserLoginAudit +{ + public long AuditID { get; set; } + public int? UserID { get; set; } + public string? EmailAttempted { get; set; } + public string? IpAddress { get; set; } + public string? UserAgent { get; set; } + public bool WasSuccessful { get; set; } + public string? Reason { get; set; } + public DateTime CreatedUtc { get; set; } +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 69a665e..1c10026 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -1,5 +1,6 @@ using PlotLine.Data; using PlotLine.Services; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; namespace PlotLine; @@ -17,7 +18,23 @@ public class Program builder.Services.AddControllersWithViews(); builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys"))); + builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.Cookie.Name = "PlotWeaver.Auth"; + options.LoginPath = "/Account/Login"; + options.LogoutPath = "/Account/Logout"; + options.AccessDeniedPath = "/Account/AccessDenied"; + options.SlidingExpiration = true; + options.ExpireTimeSpan = TimeSpan.FromDays(14); + options.Cookie.HttpOnly = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.SameSite = SameSiteMode.Lax; + }); + builder.Services.AddAuthorization(); builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -70,6 +87,11 @@ public class Program builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddScoped(_ => throw new NotImplementedException("Authentication business logic will be implemented in a later phase.")); + builder.Services.AddScoped(_ => throw new NotImplementedException("Password hashing will be implemented in a later phase.")); + builder.Services.AddScoped(_ => throw new NotImplementedException("Two-factor authentication will be implemented in a later phase.")); + builder.Services.AddScoped(_ => throw new NotImplementedException("Authentication email sending will be implemented in a later phase.")); + builder.Services.AddScoped(_ => throw new NotImplementedException("Current user access will be implemented in a later phase.")); var app = builder.Build(); @@ -83,6 +105,7 @@ public class Program app.UseStaticFiles(); app.UseRouting(); + app.UseAuthentication(); app.UseAuthorization(); app.MapStaticAssets(); diff --git a/PlotLine/Services/AuthServiceInterfaces.cs b/PlotLine/Services/AuthServiceInterfaces.cs new file mode 100644 index 0000000..cf2ee8b --- /dev/null +++ b/PlotLine/Services/AuthServiceInterfaces.cs @@ -0,0 +1,46 @@ +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface IAuthService +{ + Task RegisterAsync(RegisterViewModel model); + Task LoginAsync(LoginViewModel model); + Task CompleteTwoFactorLoginAsync(TwoFactorViewModel model); + Task LogoutAsync(); + Task VerifyEmailAsync(Guid token); + Task ForgotPasswordAsync(ForgotPasswordViewModel model); + Task ResetPasswordAsync(ResetPasswordViewModel model); + Task ChangePasswordAsync(ChangePasswordViewModel model); + Task EnableTwoFactorAsync(int userId); + Task DisableTwoFactorAsync(int userId); + Task> GenerateRecoveryCodesAsync(int userId); +} + +public interface IPasswordService +{ + string HashPassword(string password); + bool VerifyPassword(string password, string passwordHash); +} + +public interface ITwoFactorService +{ + string GenerateSecretKey(); + string GenerateQrCodeUri(string email, string secretKey); + bool ValidateCode(string secretKey, string code); + IReadOnlyList GenerateRecoveryCodes(int count); +} + +public interface IEmailService +{ + Task SendVerificationEmailAsync(string email, string displayName, Guid token); + Task SendPasswordResetEmailAsync(string email, string displayName, Guid token); +} + +public interface ICurrentUserService +{ + int? UserId { get; } + string? Email { get; } + string? DisplayName { get; } + bool IsAuthenticated { get; } +} diff --git a/PlotLine/Sql/037_Phase4A_BespokeAuthFoundation.sql b/PlotLine/Sql/037_Phase4A_BespokeAuthFoundation.sql new file mode 100644 index 0000000..a325053 --- /dev/null +++ b/PlotLine/Sql/037_Phase4A_BespokeAuthFoundation.sql @@ -0,0 +1,395 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.AppUser', N'U') IS NULL +BEGIN + CREATE TABLE dbo.AppUser + ( + UserID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_AppUser PRIMARY KEY, + Email nvarchar(256) NOT NULL, + DisplayName nvarchar(200) NOT NULL, + PasswordHash nvarchar(max) NOT NULL, + EmailConfirmed bit NOT NULL CONSTRAINT DF_AppUser_EmailConfirmed DEFAULT 0, + IsLocked bit NOT NULL CONSTRAINT DF_AppUser_IsLocked DEFAULT 0, + FailedLoginAttempts int NOT NULL CONSTRAINT DF_AppUser_FailedLoginAttempts DEFAULT 0, + LockoutEndUtc datetime2 NULL, + TwoFactorEnabled bit NOT NULL CONSTRAINT DF_AppUser_TwoFactorEnabled DEFAULT 0, + CreatedUtc datetime2 NOT NULL CONSTRAINT DF_AppUser_CreatedUtc DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_AppUser_UpdatedUtc DEFAULT SYSUTCDATETIME(), + LastLoginUtc datetime2 NULL + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_AppUser_Email' AND object_id = OBJECT_ID(N'dbo.AppUser')) + CREATE UNIQUE INDEX UX_AppUser_Email ON dbo.AppUser(Email); +GO + +IF OBJECT_ID(N'dbo.UserEmailVerificationToken', N'U') IS NULL +BEGIN + CREATE TABLE dbo.UserEmailVerificationToken + ( + TokenID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_UserEmailVerificationToken PRIMARY KEY, + UserID int NOT NULL, + Token uniqueidentifier NOT NULL, + ExpiryUtc datetime2 NOT NULL, + UsedUtc datetime2 NULL, + CONSTRAINT FK_UserEmailVerificationToken_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_UserEmailVerificationToken_Token' AND object_id = OBJECT_ID(N'dbo.UserEmailVerificationToken')) + CREATE UNIQUE INDEX UX_UserEmailVerificationToken_Token ON dbo.UserEmailVerificationToken(Token); +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_UserEmailVerificationToken_UserID' AND object_id = OBJECT_ID(N'dbo.UserEmailVerificationToken')) + CREATE INDEX IX_UserEmailVerificationToken_UserID ON dbo.UserEmailVerificationToken(UserID, ExpiryUtc DESC); +GO + +IF OBJECT_ID(N'dbo.UserPasswordResetToken', N'U') IS NULL +BEGIN + CREATE TABLE dbo.UserPasswordResetToken + ( + TokenID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_UserPasswordResetToken PRIMARY KEY, + UserID int NOT NULL, + Token uniqueidentifier NOT NULL, + ExpiryUtc datetime2 NOT NULL, + UsedUtc datetime2 NULL, + CONSTRAINT FK_UserPasswordResetToken_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'UX_UserPasswordResetToken_Token' AND object_id = OBJECT_ID(N'dbo.UserPasswordResetToken')) + CREATE UNIQUE INDEX UX_UserPasswordResetToken_Token ON dbo.UserPasswordResetToken(Token); +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_UserPasswordResetToken_UserID' AND object_id = OBJECT_ID(N'dbo.UserPasswordResetToken')) + CREATE INDEX IX_UserPasswordResetToken_UserID ON dbo.UserPasswordResetToken(UserID, ExpiryUtc DESC); +GO + +IF OBJECT_ID(N'dbo.UserTwoFactor', N'U') IS NULL +BEGIN + CREATE TABLE dbo.UserTwoFactor + ( + UserID int NOT NULL CONSTRAINT PK_UserTwoFactor PRIMARY KEY, + SecretKey nvarchar(500) NOT NULL, + RecoveryCodes nvarchar(max) NULL, + CreatedUtc datetime2 NOT NULL CONSTRAINT DF_UserTwoFactor_CreatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_UserTwoFactor_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID) + ); +END; +GO + +IF OBJECT_ID(N'dbo.UserLoginAudit', N'U') IS NULL +BEGIN + CREATE TABLE dbo.UserLoginAudit + ( + AuditID bigint IDENTITY(1,1) NOT NULL CONSTRAINT PK_UserLoginAudit PRIMARY KEY, + UserID int NULL, + EmailAttempted nvarchar(256) NULL, + IpAddress nvarchar(100) NULL, + UserAgent nvarchar(500) NULL, + WasSuccessful bit NOT NULL, + Reason nvarchar(200) NULL, + CreatedUtc datetime2 NOT NULL CONSTRAINT DF_UserLoginAudit_CreatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT FK_UserLoginAudit_AppUser FOREIGN KEY (UserID) REFERENCES dbo.AppUser(UserID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_UserLoginAudit_UserCreated' AND object_id = OBJECT_ID(N'dbo.UserLoginAudit')) + CREATE INDEX IX_UserLoginAudit_UserCreated ON dbo.UserLoginAudit(UserID, CreatedUtc DESC); +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_UserLoginAudit_EmailCreated' AND object_id = OBJECT_ID(N'dbo.UserLoginAudit')) + CREATE INDEX IX_UserLoginAudit_EmailCreated ON dbo.UserLoginAudit(EmailAttempted, CreatedUtc DESC); +GO + +CREATE OR ALTER PROCEDURE dbo.User_GetByEmail + @Email nvarchar(256) +AS +BEGIN + SET NOCOUNT ON; + + SELECT UserID, Email, DisplayName, PasswordHash, EmailConfirmed, IsLocked, FailedLoginAttempts, + LockoutEndUtc, TwoFactorEnabled, CreatedUtc, UpdatedUtc, LastLoginUtc + FROM dbo.AppUser + WHERE Email = @Email; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.User_GetById + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT UserID, Email, DisplayName, PasswordHash, EmailConfirmed, IsLocked, FailedLoginAttempts, + LockoutEndUtc, TwoFactorEnabled, CreatedUtc, UpdatedUtc, LastLoginUtc + FROM dbo.AppUser + WHERE UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.User_Create + @Email nvarchar(256), + @DisplayName nvarchar(200), + @PasswordHash nvarchar(max) +AS +BEGIN + SET NOCOUNT ON; + + INSERT INTO dbo.AppUser (Email, DisplayName, PasswordHash) + VALUES (@Email, @DisplayName, @PasswordHash); + + DECLARE @UserID int = SCOPE_IDENTITY(); + + EXEC dbo.User_GetById @UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.User_UpdateLoginSuccess + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.AppUser + SET FailedLoginAttempts = 0, + IsLocked = 0, + LockoutEndUtc = NULL, + LastLoginUtc = SYSUTCDATETIME(), + UpdatedUtc = SYSUTCDATETIME() + WHERE UserID = @UserID; + + EXEC dbo.User_GetById @UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.User_UpdateFailedLogin + @UserID int, + @LockoutEndUtc datetime2 = NULL +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.AppUser + SET FailedLoginAttempts = FailedLoginAttempts + 1, + IsLocked = CASE WHEN @LockoutEndUtc IS NULL THEN IsLocked ELSE 1 END, + LockoutEndUtc = COALESCE(@LockoutEndUtc, LockoutEndUtc), + UpdatedUtc = SYSUTCDATETIME() + WHERE UserID = @UserID; + + EXEC dbo.User_GetById @UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.User_CreateEmailVerificationToken + @UserID int, + @Token uniqueidentifier, + @ExpiryUtc datetime2 +AS +BEGIN + SET NOCOUNT ON; + + INSERT INTO dbo.UserEmailVerificationToken (UserID, Token, ExpiryUtc) + VALUES (@UserID, @Token, @ExpiryUtc); + + DECLARE @TokenID int = SCOPE_IDENTITY(); + + SELECT TokenID, UserID, Token, ExpiryUtc, UsedUtc + FROM dbo.UserEmailVerificationToken + WHERE TokenID = @TokenID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.User_GetEmailVerificationToken + @Token uniqueidentifier +AS +BEGIN + SET NOCOUNT ON; + + SELECT TokenID, UserID, Token, ExpiryUtc, UsedUtc + FROM dbo.UserEmailVerificationToken + WHERE Token = @Token; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.User_ConfirmEmail + @UserID int, + @Token uniqueidentifier +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.UserEmailVerificationToken + SET UsedUtc = SYSUTCDATETIME() + WHERE UserID = @UserID + AND Token = @Token + AND UsedUtc IS NULL + AND ExpiryUtc >= SYSUTCDATETIME(); + + IF @@ROWCOUNT = 1 + BEGIN + UPDATE dbo.AppUser + SET EmailConfirmed = 1, + UpdatedUtc = SYSUTCDATETIME() + WHERE UserID = @UserID; + + SELECT CAST(1 AS bit); + RETURN; + END; + + SELECT CAST(0 AS bit); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.User_CreatePasswordResetToken + @UserID int, + @Token uniqueidentifier, + @ExpiryUtc datetime2 +AS +BEGIN + SET NOCOUNT ON; + + INSERT INTO dbo.UserPasswordResetToken (UserID, Token, ExpiryUtc) + VALUES (@UserID, @Token, @ExpiryUtc); + + DECLARE @TokenID int = SCOPE_IDENTITY(); + + SELECT TokenID, UserID, Token, ExpiryUtc, UsedUtc + FROM dbo.UserPasswordResetToken + WHERE TokenID = @TokenID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.User_GetPasswordResetToken + @Token uniqueidentifier +AS +BEGIN + SET NOCOUNT ON; + + SELECT TokenID, UserID, Token, ExpiryUtc, UsedUtc + FROM dbo.UserPasswordResetToken + WHERE Token = @Token; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.User_ResetPassword + @UserID int, + @Token uniqueidentifier, + @PasswordHash nvarchar(max) +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.UserPasswordResetToken + SET UsedUtc = SYSUTCDATETIME() + WHERE UserID = @UserID + AND Token = @Token + AND UsedUtc IS NULL + AND ExpiryUtc >= SYSUTCDATETIME(); + + IF @@ROWCOUNT = 1 + BEGIN + UPDATE dbo.AppUser + SET PasswordHash = @PasswordHash, + FailedLoginAttempts = 0, + IsLocked = 0, + LockoutEndUtc = NULL, + UpdatedUtc = SYSUTCDATETIME() + WHERE UserID = @UserID; + + SELECT CAST(1 AS bit); + RETURN; + END; + + SELECT CAST(0 AS bit); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.User_EnableTwoFactor + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.AppUser + SET TwoFactorEnabled = 1, + UpdatedUtc = SYSUTCDATETIME() + WHERE UserID = @UserID; + + EXEC dbo.User_GetById @UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.User_DisableTwoFactor + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.AppUser + SET TwoFactorEnabled = 0, + UpdatedUtc = SYSUTCDATETIME() + WHERE UserID = @UserID; + + EXEC dbo.User_GetById @UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.User_GetTwoFactor + @UserID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT UserID, SecretKey, RecoveryCodes, CreatedUtc + FROM dbo.UserTwoFactor + WHERE UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.User_SaveTwoFactor + @UserID int, + @SecretKey nvarchar(500), + @RecoveryCodes nvarchar(max) = NULL +AS +BEGIN + SET NOCOUNT ON; + + MERGE dbo.UserTwoFactor AS target + USING (SELECT @UserID AS UserID, @SecretKey AS SecretKey, @RecoveryCodes AS RecoveryCodes) AS source + ON target.UserID = source.UserID + WHEN MATCHED THEN + UPDATE SET SecretKey = source.SecretKey, + RecoveryCodes = source.RecoveryCodes + WHEN NOT MATCHED THEN + INSERT (UserID, SecretKey, RecoveryCodes) + VALUES (source.UserID, source.SecretKey, source.RecoveryCodes); + + EXEC dbo.User_GetTwoFactor @UserID = @UserID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.User_InsertLoginAudit + @UserID int = NULL, + @EmailAttempted nvarchar(256) = NULL, + @IpAddress nvarchar(100) = NULL, + @UserAgent nvarchar(500) = NULL, + @WasSuccessful bit, + @Reason nvarchar(200) = NULL +AS +BEGIN + SET NOCOUNT ON; + + INSERT INTO dbo.UserLoginAudit (UserID, EmailAttempted, IpAddress, UserAgent, WasSuccessful, Reason) + VALUES (@UserID, @EmailAttempted, @IpAddress, @UserAgent, @WasSuccessful, @Reason); + + SELECT CAST(SCOPE_IDENTITY() AS bigint); +END; +GO diff --git a/PlotLine/ViewModels/AuthViewModels.cs b/PlotLine/ViewModels/AuthViewModels.cs new file mode 100644 index 0000000..458254d --- /dev/null +++ b/PlotLine/ViewModels/AuthViewModels.cs @@ -0,0 +1,100 @@ +using System.ComponentModel.DataAnnotations; + +namespace PlotLine.ViewModels; + +public sealed class RegisterViewModel +{ + [Required(ErrorMessage = "Enter your email address.")] + [EmailAddress(ErrorMessage = "Enter a valid email address.")] + [StringLength(256, ErrorMessage = "Email must be 256 characters or fewer.")] + public string Email { get; set; } = string.Empty; + + [Required(ErrorMessage = "Enter a display name.")] + [StringLength(200, ErrorMessage = "Display name must be 200 characters or fewer.")] + public string DisplayName { get; set; } = string.Empty; + + [Required(ErrorMessage = "Enter a password.")] + [StringLength(200, MinimumLength = 12, ErrorMessage = "Password must be at least 12 characters.")] + [DataType(DataType.Password)] + public string Password { get; set; } = string.Empty; + + [Required(ErrorMessage = "Confirm your password.")] + [DataType(DataType.Password)] + [Compare(nameof(Password), ErrorMessage = "Passwords do not match.")] + public string ConfirmPassword { get; set; } = string.Empty; +} + +public sealed class LoginViewModel +{ + [Required(ErrorMessage = "Enter your email address.")] + [EmailAddress(ErrorMessage = "Enter a valid email address.")] + [StringLength(256, ErrorMessage = "Email must be 256 characters or fewer.")] + public string Email { get; set; } = string.Empty; + + [Required(ErrorMessage = "Enter your password.")] + [DataType(DataType.Password)] + public string Password { get; set; } = string.Empty; + + public bool RememberMe { get; set; } + + public string? ReturnUrl { get; set; } +} + +public sealed class ForgotPasswordViewModel +{ + [Required(ErrorMessage = "Enter your email address.")] + [EmailAddress(ErrorMessage = "Enter a valid email address.")] + [StringLength(256, ErrorMessage = "Email must be 256 characters or fewer.")] + public string Email { get; set; } = string.Empty; +} + +public sealed class ResetPasswordViewModel +{ + [Required(ErrorMessage = "Enter your email address.")] + [EmailAddress(ErrorMessage = "Enter a valid email address.")] + [StringLength(256, ErrorMessage = "Email must be 256 characters or fewer.")] + public string Email { get; set; } = string.Empty; + + [Required(ErrorMessage = "Password reset token is missing.")] + public Guid Token { get; set; } + + [Required(ErrorMessage = "Enter a new password.")] + [StringLength(200, MinimumLength = 12, ErrorMessage = "Password must be at least 12 characters.")] + [DataType(DataType.Password)] + public string Password { get; set; } = string.Empty; + + [Required(ErrorMessage = "Confirm your new password.")] + [DataType(DataType.Password)] + [Compare(nameof(Password), ErrorMessage = "Passwords do not match.")] + public string ConfirmPassword { get; set; } = string.Empty; +} + +public sealed class TwoFactorViewModel +{ + [Required(ErrorMessage = "Enter your authentication code.")] + [StringLength(20, ErrorMessage = "Authentication code is too long.")] + public string Code { get; set; } = string.Empty; + + public string? ReturnUrl { get; set; } + + public bool RememberMachine { get; set; } + + public bool RememberMe { get; set; } +} + +public sealed class ChangePasswordViewModel +{ + [Required(ErrorMessage = "Enter your current password.")] + [DataType(DataType.Password)] + public string CurrentPassword { get; set; } = string.Empty; + + [Required(ErrorMessage = "Enter a new password.")] + [StringLength(200, MinimumLength = 12, ErrorMessage = "Password must be at least 12 characters.")] + [DataType(DataType.Password)] + public string NewPassword { get; set; } = string.Empty; + + [Required(ErrorMessage = "Confirm your new password.")] + [DataType(DataType.Password)] + [Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match.")] + public string ConfirmPassword { get; set; } = string.Empty; +}