Phase 4A AuthService

This commit is contained in:
Nick Beckley 2026-06-06 17:21:58 +01:00
parent 397035981c
commit 6f42c65e35
12 changed files with 1074 additions and 0 deletions

View File

@ -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"
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>

View File

@ -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"
}
]
}

View File

@ -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"
}
}

View File

@ -0,0 +1,187 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IUserRepository
{
Task<AppUser?> GetByEmailAsync(string email);
Task<AppUser?> GetByIdAsync(int userId);
Task<AppUser> CreateAsync(string email, string displayName, string passwordHash);
Task<AppUser?> UpdateLoginSuccessAsync(int userId);
Task<AppUser?> UpdateFailedLoginAsync(int userId, DateTime? lockoutEndUtc);
Task<bool> ConfirmEmailAsync(int userId, Guid token);
Task<bool> ResetPasswordAsync(int userId, Guid token, string passwordHash);
Task<AppUser?> EnableTwoFactorAsync(int userId);
Task<AppUser?> DisableTwoFactorAsync(int userId);
}
public interface IAuthenticationRepository
{
Task<UserEmailVerificationToken> CreateEmailVerificationTokenAsync(int userId, Guid token, DateTime expiryUtc);
Task<UserEmailVerificationToken?> GetEmailVerificationTokenAsync(Guid token);
Task<UserPasswordResetToken> CreatePasswordResetTokenAsync(int userId, Guid token, DateTime expiryUtc);
Task<UserPasswordResetToken?> GetPasswordResetTokenAsync(Guid token);
Task<UserTwoFactor?> GetTwoFactorAsync(int userId);
Task<UserTwoFactor> SaveTwoFactorAsync(int userId, string secretKey, string? recoveryCodes);
Task<long> InsertLoginAuditAsync(UserLoginAudit audit);
}
public sealed class UserRepository(ISqlConnectionFactory connectionFactory) : IUserRepository
{
public async Task<AppUser?> GetByEmailAsync(string email)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<AppUser>(
"dbo.User_GetByEmail",
new { Email = email },
commandType: CommandType.StoredProcedure);
}
public async Task<AppUser?> GetByIdAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<AppUser>(
"dbo.User_GetById",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<AppUser> CreateAsync(string email, string displayName, string passwordHash)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<AppUser>(
"dbo.User_Create",
new { Email = email, DisplayName = displayName, PasswordHash = passwordHash },
commandType: CommandType.StoredProcedure);
}
public async Task<AppUser?> UpdateLoginSuccessAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<AppUser>(
"dbo.User_UpdateLoginSuccess",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<AppUser?> UpdateFailedLoginAsync(int userId, DateTime? lockoutEndUtc)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<AppUser>(
"dbo.User_UpdateFailedLogin",
new { UserID = userId, LockoutEndUtc = lockoutEndUtc },
commandType: CommandType.StoredProcedure);
}
public async Task<bool> ConfirmEmailAsync(int userId, Guid token)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<bool>(
"dbo.User_ConfirmEmail",
new { UserID = userId, Token = token },
commandType: CommandType.StoredProcedure);
}
public async Task<bool> ResetPasswordAsync(int userId, Guid token, string passwordHash)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<bool>(
"dbo.User_ResetPassword",
new { UserID = userId, Token = token, PasswordHash = passwordHash },
commandType: CommandType.StoredProcedure);
}
public async Task<AppUser?> EnableTwoFactorAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<AppUser>(
"dbo.User_EnableTwoFactor",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<AppUser?> DisableTwoFactorAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<AppUser>(
"dbo.User_DisableTwoFactor",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
}
public sealed class AuthenticationRepository(ISqlConnectionFactory connectionFactory) : IAuthenticationRepository
{
public async Task<UserEmailVerificationToken> CreateEmailVerificationTokenAsync(int userId, Guid token, DateTime expiryUtc)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<UserEmailVerificationToken>(
"dbo.User_CreateEmailVerificationToken",
new { UserID = userId, Token = token, ExpiryUtc = expiryUtc },
commandType: CommandType.StoredProcedure);
}
public async Task<UserEmailVerificationToken?> GetEmailVerificationTokenAsync(Guid token)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<UserEmailVerificationToken>(
"dbo.User_GetEmailVerificationToken",
new { Token = token },
commandType: CommandType.StoredProcedure);
}
public async Task<UserPasswordResetToken> CreatePasswordResetTokenAsync(int userId, Guid token, DateTime expiryUtc)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<UserPasswordResetToken>(
"dbo.User_CreatePasswordResetToken",
new { UserID = userId, Token = token, ExpiryUtc = expiryUtc },
commandType: CommandType.StoredProcedure);
}
public async Task<UserPasswordResetToken?> GetPasswordResetTokenAsync(Guid token)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<UserPasswordResetToken>(
"dbo.User_GetPasswordResetToken",
new { Token = token },
commandType: CommandType.StoredProcedure);
}
public async Task<UserTwoFactor?> GetTwoFactorAsync(int userId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<UserTwoFactor>(
"dbo.User_GetTwoFactor",
new { UserID = userId },
commandType: CommandType.StoredProcedure);
}
public async Task<UserTwoFactor> SaveTwoFactorAsync(int userId, string secretKey, string? recoveryCodes)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<UserTwoFactor>(
"dbo.User_SaveTwoFactor",
new { UserID = userId, SecretKey = secretKey, RecoveryCodes = recoveryCodes },
commandType: CommandType.StoredProcedure);
}
public async Task<long> InsertLoginAuditAsync(UserLoginAudit audit)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<long>(
"dbo.User_InsertLoginAudit",
new
{
audit.UserID,
audit.EmailAttempted,
audit.IpAddress,
audit.UserAgent,
audit.WasSuccessful,
audit.Reason
},
commandType: CommandType.StoredProcedure);
}
}

View File

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

View File

@ -1,5 +1,6 @@
using PlotLine.Data; using PlotLine.Data;
using PlotLine.Services; using PlotLine.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
namespace PlotLine; namespace PlotLine;
@ -17,7 +18,23 @@ public class Program
builder.Services.AddControllersWithViews(); builder.Services.AddControllersWithViews();
builder.Services.AddDataProtection() builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys"))); .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<ISqlConnectionFactory, SqlConnectionFactory>(); builder.Services.AddSingleton<ISqlConnectionFactory, SqlConnectionFactory>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IAuthenticationRepository, AuthenticationRepository>();
builder.Services.AddScoped<IProjectRepository, ProjectRepository>(); builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
builder.Services.AddScoped<IBookRepository, BookRepository>(); builder.Services.AddScoped<IBookRepository, BookRepository>();
builder.Services.AddScoped<IChapterRepository, ChapterRepository>(); builder.Services.AddScoped<IChapterRepository, ChapterRepository>();
@ -70,6 +87,11 @@ public class Program
builder.Services.AddScoped<IImportService, ImportService>(); builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddSingleton<IHelpService, HelpService>(); builder.Services.AddSingleton<IHelpService, HelpService>();
builder.Services.AddSingleton<IHelpMarkdownRenderer, HelpMarkdownRenderer>(); builder.Services.AddSingleton<IHelpMarkdownRenderer, HelpMarkdownRenderer>();
builder.Services.AddScoped<IAuthService>(_ => throw new NotImplementedException("Authentication business logic will be implemented in a later phase."));
builder.Services.AddScoped<IPasswordService>(_ => throw new NotImplementedException("Password hashing will be implemented in a later phase."));
builder.Services.AddScoped<ITwoFactorService>(_ => throw new NotImplementedException("Two-factor authentication will be implemented in a later phase."));
builder.Services.AddScoped<IEmailService>(_ => throw new NotImplementedException("Authentication email sending will be implemented in a later phase."));
builder.Services.AddScoped<ICurrentUserService>(_ => throw new NotImplementedException("Current user access will be implemented in a later phase."));
var app = builder.Build(); var app = builder.Build();
@ -83,6 +105,7 @@ public class Program
app.UseStaticFiles(); app.UseStaticFiles();
app.UseRouting(); app.UseRouting();
app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapStaticAssets(); app.MapStaticAssets();

View File

@ -0,0 +1,46 @@
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IAuthService
{
Task<bool> RegisterAsync(RegisterViewModel model);
Task<bool> LoginAsync(LoginViewModel model);
Task<bool> CompleteTwoFactorLoginAsync(TwoFactorViewModel model);
Task LogoutAsync();
Task<bool> VerifyEmailAsync(Guid token);
Task<bool> ForgotPasswordAsync(ForgotPasswordViewModel model);
Task<bool> ResetPasswordAsync(ResetPasswordViewModel model);
Task<bool> ChangePasswordAsync(ChangePasswordViewModel model);
Task<bool> EnableTwoFactorAsync(int userId);
Task<bool> DisableTwoFactorAsync(int userId);
Task<IReadOnlyList<string>> 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<string> 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; }
}

View File

@ -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

View File

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