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;
+}