diff --git a/DapperIdentity.Cookies.Server/DapperIdentityCookieServiceCollectionExtensions.cs b/DapperIdentity.Cookies.Server/DapperIdentityCookieServiceCollectionExtensions.cs index cc33578..4455aa7 100644 --- a/DapperIdentity.Cookies.Server/DapperIdentityCookieServiceCollectionExtensions.cs +++ b/DapperIdentity.Cookies.Server/DapperIdentityCookieServiceCollectionExtensions.cs @@ -89,6 +89,7 @@ public static IServiceCollection AddDapperIdentityWithCustomCookies(this IServic { services.TryAddDapperIdentityDatabaseStores(); + services.TryAddSignInReporter(); // CustomSignInManager reports each sign-in. services.AddIdentity() .AddDefaultTokenProviders() .AddSignInManager() @@ -151,9 +152,13 @@ public static IServiceCollection AddDapperIdentityWithVanillaUIAndDefaults(this bool slidingExpiration = true) { services.TryAddDapperIdentityDatabaseStores(); + services.TryAddSignInReporter(); // CustomSignInManager reports each sign-in. + // CustomSignInManager here too: without it the IsEnabled column is ignored and sign-ins + // through the Identity UI pages are not reported. services.AddIdentity() //.AddDefaultUI() + .AddSignInManager() .AddDefaultTokenProviders(); services.Configure(opts => diff --git a/DapperIdentity.Jwt.Server/Controllers/JwtAuthController.cs b/DapperIdentity.Jwt.Server/Controllers/JwtAuthController.cs index 77d5253..43797dc 100644 --- a/DapperIdentity.Jwt.Server/Controllers/JwtAuthController.cs +++ b/DapperIdentity.Jwt.Server/Controllers/JwtAuthController.cs @@ -1,4 +1,5 @@ -using CPE.DapperIdentity.Stores.Models; +using CPE.DapperIdentity.Stores; +using CPE.DapperIdentity.Stores.Models; using CPE.DapperIdentity.Abstractions.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -50,6 +51,8 @@ public class JwtAuthController : ControllerBase private readonly IOptions _TokenOptions; + private readonly CustomSignInManager _SignInManager; + /// Captures the services the endpoints need. /// Identity's user manager. /// Issues access and refresh tokens. @@ -64,6 +67,10 @@ public class JwtAuthController : ControllerBase /// The token provider's settings, read for the link lifetime quoted in emails - the value the /// server actually enforces, however it was set. /// + /// + /// Checks a sign-in the way the cookie sign-in does (enabled, confirmed, not locked out, then the + /// password) and reports it. + /// public JwtAuthController(UserManager userManager, TokenService tokenService, IAuthEmailSender emailSender, @@ -71,7 +78,8 @@ public JwtAuthController(UserManager userManager, IAppSettings appSettings, AppBaseUrl appBaseUrl, PasswordResetRateLimiter resetRateLimiter, - IOptions tokenOptions)//Todo: Add options, IOptions options) //ApplicationDbContext context + IOptions tokenOptions, + CustomSignInManager signInManager)//Todo: Add options, IOptions options) //ApplicationDbContext context { _TokenOptions = tokenOptions; _userManager = userManager; @@ -82,6 +90,7 @@ public JwtAuthController(UserManager userManager, _AppSettings = appSettings; _AppBaseUrl = appBaseUrl; _ResetRateLimiter = resetRateLimiter; + _SignInManager = signInManager; } /// @@ -339,18 +348,15 @@ public async Task> Authenticate([FromBody] AuthReques return BadRequest(ModelState); } - var managedUser = await _userManager.FindByEmailAsync(request.Email!); + // Through the sign-in manager, not UserManager.CheckPasswordAsync: that checks only the + // password hash, so a disabled, unconfirmed or locked-out user used to get a token. Every + // refusal answers alike, so the reply does not say which accounts exist or are disabled. + var (_, managedUser) = await _SignInManager.CheckPasswordByEmailAsync(request.Email!, request.Password!); if (managedUser == null) { return BadRequest("Bad credentials"); } - var isPasswordValid = await _userManager.CheckPasswordAsync(managedUser, request.Password!); - if (!isPasswordValid) - { - return BadRequest("Bad credentials"); - } - var userInDb = managedUser; //why search again?//_userManager.Users.FirstOrDefault(u=>u.Email==) //_context.Users.FirstOrDefault(u => u.Email == request.Email); if (userInDb is null) @@ -414,6 +420,11 @@ public async Task> Refresh([FromBody] RefreshTokenDto var username = principal.Identity!.Name; //do we need to null check on Identity? var user = await _userManager.FindByNameAsync(username);// EmailAsync(username); if (user == null || user.RefreshToken != tokenDto.RefreshToken || user.RefreshTokenExpireTime <= DateTime.Now) + return BadRequest("Invalid access token or refresh token"); + + // A refresh has no password to check, so without this a user disabled or locked out after + // signing in would keep renewing tokens for as long as they kept refreshing. + if (!await _SignInManager.AllowsSignInAsync(user)) return BadRequest("Invalid access token or refresh token");//return BadRequest(new AuthResponseDto { IsAuthSuccessful = false, ErrorMessage = "Invalid client request" }); var roles = await _userManager.GetRolesAsync(user); diff --git a/DapperIdentity.Jwt.Server/ServiceCollectionExtensions.cs b/DapperIdentity.Jwt.Server/ServiceCollectionExtensions.cs index 3e40009..a509f21 100644 --- a/DapperIdentity.Jwt.Server/ServiceCollectionExtensions.cs +++ b/DapperIdentity.Jwt.Server/ServiceCollectionExtensions.cs @@ -88,6 +88,7 @@ public static IServiceCollection AddJwtIdentity(this IServiceCollection services .ValidateOnStart(); services.TryAddDapperIdentityDatabaseStores(); + services.TryAddSignInReporter(); // JwtAuthController reports each sign-in. services.AddScoped(); // Route JwtAuthController and nothing else from this assembly. Adding the AssemblyPart on // its own would hand the consumer every controller this library happens to contain, now diff --git a/DapperIdentity.Stores/CustomSignInManager.cs b/DapperIdentity.Stores/CustomSignInManager.cs index b699d59..66d8d96 100644 --- a/DapperIdentity.Stores/CustomSignInManager.cs +++ b/DapperIdentity.Stores/CustomSignInManager.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using CPE.DapperIdentity.Stores.SignIn; using IdentityUser = CPE.DapperIdentity.Stores.Models.CustomIdentityUser; @@ -17,12 +18,22 @@ namespace CPE.DapperIdentity.Stores /// extended with the library's own IsEnabled check. /// /// - /// The only behaviour added is in : a user whose IsEnabled column - /// is false is refused even when the password is correct. Register it in place of the stock - /// sign-in manager, or the column has no effect. + /// + /// refuses a user whose IsEnabled column is false even when the + /// password is correct. Register it in place of the stock sign-in manager, or the column has no effect. + /// + /// + /// It is also the one place sign-ins are reported (): cookie sign-ins + /// through , token endpoints through + /// . Token endpoints must use that rather than + /// UserManager.CheckPasswordAsync, which checks only the password hash and so lets a + /// disabled, unconfirmed or locked-out user through. + /// /// public class CustomSignInManager : SignInManager { + private readonly ISignInReporter? _signInReporter; + /// Passes every dependency through to the base sign-in manager. /// The user manager. /// Accessor for the current HTTP context. @@ -40,6 +51,102 @@ public CustomSignInManager(UserManager userManager, IUserConfirmation confirmation) : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) { } + /// As the other constructor, and reports every sign-in to . + /// The user manager. + /// Accessor for the current HTTP context. + /// Builds the claims principal for a signed-in user. + /// The configured Identity options. + /// Logger for the base sign-in manager. + /// The registered authentication schemes. + /// Decides whether a user counts as confirmed. + /// Told of each sign-in, so a tool like PortGuardian can ban an address that keeps failing. + public CustomSignInManager(UserManager userManager, + IHttpContextAccessor contextAccessor, + IUserClaimsPrincipalFactory claimsFactory, + IOptions optionsAccessor, + ILogger> logger, + IAuthenticationSchemeProvider schemes, + IUserConfirmation confirmation, + ISignInReporter signInReporter) : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) + { + _signInReporter = signInReporter; + } + + /// + /// Signs in by user name, as the base does, and reports the attempt. An unknown name is + /// reported here because it never reaches the password check. + /// + /// + public override async Task PasswordSignInAsync(string userName, string password, bool isPersistent, bool lockoutOnFailure) + { + var user = await UserManager.FindByNameAsync(userName); + if (user is null) + { + Report(SignInAttempt.Failure(CurrentContext(), userName, SignInFailure.UnknownUser, "cookie")); + return SignInResult.Failed; + } + + var result = await PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure); + Report(SignInAttempt.FromSignInResult(result, CurrentContext(), userName, "cookie")); + return result; + } + + /// + /// Checks an email and password the way a sign-in does - enabled, confirmed, not locked out, + /// then the password - without issuing a cookie, and reports the attempt. For token endpoints, + /// so they refuse exactly whom a cookie sign-in refuses. + /// + /// The email as typed. + /// The password as typed. + /// Which endpoint, for the report. + /// The user when the check passed; otherwise no user and the reason in Result. + public async Task<(SignInResult Result, IdentityUser? User)> CheckPasswordByEmailAsync(string email, string password, string path = "jwt") + { + var user = await UserManager.FindByEmailAsync(email); + if (user is null) + { + Report(SignInAttempt.Failure(CurrentContext(), email, SignInFailure.UnknownUser, path)); + return (SignInResult.Failed, null); + } + + // No lockout counting, as the cookie sign-in (lockoutOnFailure: false); PortGuardian bans + // the address instead, which does not let an attacker lock a real user out. + var result = await CheckPasswordSignInAsync(user, password, lockoutOnFailure: false); + Report(SignInAttempt.FromSignInResult(result, CurrentContext(), email, path)); + return (result, result.Succeeded ? user : null); + } + + /// + /// Whether the user may sign in now - enabled, confirmed, not locked out. For a token refresh, + /// which has no password to check but must stop working for a user who was disabled. + /// + /// The user whose token is being refreshed. + public async Task AllowsSignInAsync(IdentityUser user) => await PreSignInCheck(user) is null; + + private HttpContext? CurrentContext() + { + // The base throws when there is no request (a background job signing someone in); a + // report without an address is still worth its log line. + try { return Context; } + catch (InvalidOperationException) { return null; } + } + + private void Report(SignInAttempt? attempt) + { + if (attempt is null || _signInReporter is null) + return; + try + { + _signInReporter.Report(attempt); + } +#pragma warning disable CA1031 // A host's own reporter must not be able to break a sign-in either. + catch (Exception ex) +#pragma warning restore CA1031 + { + Logger.LogWarning(ex, "The sign-in reporter failed; the sign-in itself was not affected."); + } + } + /// /// Used to ensure that a user is allowed to sign in. diff --git a/DapperIdentity.Stores/DapperIdentity.Stores.csproj b/DapperIdentity.Stores/DapperIdentity.Stores.csproj index 74f6ac6..fb567b1 100644 --- a/DapperIdentity.Stores/DapperIdentity.Stores.csproj +++ b/DapperIdentity.Stores/DapperIdentity.Stores.csproj @@ -42,6 +42,11 @@ + + + + + diff --git a/DapperIdentity.Stores/DapperIdentityServiceCollectionExtensions.cs b/DapperIdentity.Stores/DapperIdentityServiceCollectionExtensions.cs index 5e2ed27..82a6f1b 100644 --- a/DapperIdentity.Stores/DapperIdentityServiceCollectionExtensions.cs +++ b/DapperIdentity.Stores/DapperIdentityServiceCollectionExtensions.cs @@ -1,8 +1,10 @@ using CPE.DapperIdentity.Stores; +using CPE.DapperIdentity.Stores.SignIn; using DapperRepository; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; using IdentityRole = CPE.DapperIdentity.Stores.Models.CustomIdentityRole; using IdentityUser = CPE.DapperIdentity.Stores.Models.CustomIdentityUser; @@ -47,4 +49,21 @@ public static IServiceCollection TryAddDapperIdentityDatabaseStores(this IServic return services; } + + /// + /// Registers the default : a log line on every OS, and on Windows an + /// Application-log event PortGuardian reads to ban an address that keeps failing. + /// + /// + /// Called by every sign-in set-up in these packages. TryAdd, so a host that registered its own + /// reporter first (to feed another tool, or to report nothing) keeps it. + /// + public static IServiceCollection TryAddSignInReporter(this IServiceCollection services) + { + // Names are hashed unless DapperIdentity:SignInReporting:UserNames (or a Configure call) says otherwise. + services.AddOptions(); + services.TryAddEnumerable(ServiceDescriptor.Singleton, SignInReportingOptionsFromConfiguration>()); + services.TryAddSingleton(); + return services; + } } diff --git a/DapperIdentity.Stores/SignIn/SignInReporting.cs b/DapperIdentity.Stores/SignIn/SignInReporting.cs new file mode 100644 index 0000000..3d08509 --- /dev/null +++ b/DapperIdentity.Stores/SignIn/SignInReporting.cs @@ -0,0 +1,299 @@ +using System.Diagnostics; +using System.Net; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CPE.DapperIdentity.Stores.SignIn; + +/// Why a sign-in failed. The names are PortGuardian's own, so they are written as they are. +public enum SignInFailure +{ + /// Refused for another reason before the password counted: not allowed (disabled, unconfirmed). + Unspecified, + /// The user exists and the password was wrong. + BadPassword, + /// No user has that name or email. + UnknownUser, + /// The account is locked out, so the password was not checked. + LockedOut, +} + +/// One sign-in's outcome, as the endpoint saw it. +/// Whether the sign-in went through. +/// The address it came from; null when there was no request. +/// The name or email as typed. +/// Why it failed; on success. +/// Which sign-in endpoint: cookie or jwt. +public sealed record SignInAttempt(bool Succeeded, IPAddress? ClientIp, string? UserName, SignInFailure Reason, string Path) +{ + /// A sign-in that went through, from the request's address. + public static SignInAttempt Success(HttpContext? http, string? userName, string path) => + new(true, ClientAddress(http), userName, SignInFailure.Unspecified, path); + + /// A refused sign-in, from the request's address. + public static SignInAttempt Failure(HttpContext? http, string? userName, SignInFailure reason, string path) => + new(false, ClientAddress(http), userName, reason, path); + + /// + /// What a sign-in result for a user who exists means for reporting, or null when it is neither a + /// finished sign-in nor a failed one. RequiresTwoFactor is that case: the password was right and + /// the second step is still to come. Failed, for a user who exists, is a wrong password. + /// NotAllowed and LockedOut are decided before the password is checked, so they are failed tries + /// whatever was typed. + /// + public static SignInAttempt? FromSignInResult(SignInResult result, HttpContext? http, string? userName, string path) + { + ArgumentNullException.ThrowIfNull(result); + if (result.Succeeded) + return Success(http, userName, path); + if (result.RequiresTwoFactor) + return null; + var reason = result.IsLockedOut ? SignInFailure.LockedOut + : result.IsNotAllowed ? SignInFailure.Unspecified + : SignInFailure.BadPassword; + return Failure(http, userName, reason, path); + } + + // Kestrel's dual-stack socket reports an IPv4 client as ::ffff:a.b.c.d; a firewall bans the IPv4 form. + private static IPAddress? ClientAddress(HttpContext? http) + { + var address = http?.Connection.RemoteIpAddress; + return address is { IsIPv4MappedToIPv6: true } ? address.MapToIPv4() : address; + } +} + +/// How the name typed at a failed sign-in is written. A successful sign-in never carries one. +public enum SignInUserNames +{ + /// + /// sha256: and the hash of the trimmed, lower-cased name. Enough for PortGuardian to recognise the owner's own + /// names (it hashes its configured names the same way) without writing anyone's name or email in clear. Pseudonymous, + /// not anonymous: whoever already holds a list of names can hash them and look for a match. + /// + Hashed, + + /// The name as typed (cleaned of line breaks and control characters). The most useful, and personal data. + Plain, + + /// No name at all. Bans still work - they go by address - but nothing shows which accounts are attacked. + None, +} + +/// +/// Settings for sign-in reporting, bound from DapperIdentity:SignInReporting in configuration, or set in code with +/// services.Configure<SignInReportingOptions>(...). +/// +/// "DapperIdentity": { "SignInReporting": { "UserNames": "Plain" } } +public sealed class SignInReportingOptions +{ + /// The configuration section these are read from. + public const string SectionName = "DapperIdentity:SignInReporting"; + + /// How the name typed at a failed sign-in is written; unless set. + public SignInUserNames UserNames { get; set; } = SignInUserNames.Hashed; +} + +/// +/// Binds from configuration when the host has any; a host without configuration +/// (a test, a console tool) keeps the defaults instead of failing to start. +/// +internal sealed class SignInReportingOptionsFromConfiguration(IServiceProvider services) : IConfigureOptions +{ + public void Configure(SignInReportingOptions options) => + services.GetService()?.GetSection(SignInReportingOptions.SectionName).Bind(options); +} + +/// +/// Tells something outside the app that a sign-in happened. The default logs it and, on Windows, +/// writes it to the event log for PortGuardian; register your own first to feed something else. +/// +public interface ISignInReporter +{ + /// Records one attempt. Must not throw; the caller guards against it anyway. + void Report(SignInAttempt attempt); +} + +/// +/// The event-log contract PortGuardian reads (its decision D-067). Values are positional and only +/// ever appended; rises if one changes meaning. Never a password, token or user id. +/// +public static class SignInEventFormat +{ + /// The Application-log source PortGuardian subscribes to; its installer registers it. + public const string Source = "PortGuardian.SignIn"; + /// The ILogger category, so a host can raise, lower or silence the log line. + public const string LogCategory = "CPE.DapperIdentity.SignIn"; + /// Event id of a failed sign-in. + public const int FailedId = 1000; + /// Event id of a successful sign-in. + public const int SucceededId = 1001; + /// The format version, written as value [1]. + public const string Version = "1"; + + /// The longest username written; anything past it is an attack on the log, not a name. + public const int MaxUserNameLength = 256; + + /// The prefix of a hashed name, so a reader can tell it from a name that happens to look like hex. + public const string HashPrefix = "sha256:"; + + /// + /// [0] a sentence for Event Viewer, [1] version, [2] IP, [3] the name as says (empty on + /// success), [4] reason (empty on success), [5] app, [6] path. + /// + public static string[] Values(SignInAttempt attempt, string ip, string appName, SignInUserNames userNames = SignInUserNames.Hashed) + { + ArgumentNullException.ThrowIfNull(attempt); + var user = UserNameFor(attempt, userNames); + var sentence = attempt.Succeeded + ? $"Sign-in succeeded for '{user}' from {ip} on {appName} ({attempt.Path})." + : $"Sign-in failed for '{user}' from {ip} on {appName} ({attempt.Path}): {attempt.Reason}."; + return [sentence, Version, ip, user, attempt.Succeeded ? "" : attempt.Reason.ToString(), appName, attempt.Path]; + } + + /// + /// The name to write for : nothing for a success (the address is all it is reported for), + /// otherwise the typed name hashed, plain or left out. + /// + public static string UserNameFor(SignInAttempt attempt, SignInUserNames userNames) + { + ArgumentNullException.ThrowIfNull(attempt); + if (attempt.Succeeded || userNames == SignInUserNames.None) + return ""; + + var clean = Clean(attempt.UserName); + return userNames == SignInUserNames.Plain ? clean : Hash(clean); + } + + /// + /// and the SHA-256 of the trimmed, lower-cased name, in lower-case hex; empty for no name. + /// PortGuardian computes the same for its owner names, so this is a contract: the normalisation must not change + /// without a new . + /// + public static string Hash(string? userName) + { + var normalised = Clean(userName).Trim().ToLowerInvariant(); + if (normalised.Length == 0) + return ""; + + return HashPrefix + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalised))).ToLowerInvariant(); + } + + /// + /// A username as typed, made safe to write into a log: line breaks and other control characters removed, and cut + /// to . It is the one value an attacker chooses freely, so without this a "name" + /// holding a line break could forge a second log entry, and a megabyte one could fill the log. + /// + public static string Clean(string? userName) + { + if (string.IsNullOrEmpty(userName)) + return ""; + + // The newline replacements first and explicitly: they are what log readers and scanners (CodeQL's log-forging + // rule) look for; the pass after catches every other control character. + var withoutBreaks = userName.Replace("\r", "", StringComparison.Ordinal).Replace("\n", "", StringComparison.Ordinal); + var cleaned = new string(withoutBreaks.Where(c => !char.IsControl(c)).ToArray()); + return cleaned.Length <= MaxUserNameLength ? cleaned : cleaned[..MaxUserNameLength]; + } +} + +/// +/// The default reporter: one log line on every OS, and on Windows one Application-log event under +/// . Never throws: a sign-in must not fail because reporting did. +/// +public sealed class SignInReporter : ISignInReporter +{ + private readonly string _appName; + private readonly ILogger _logger; + private readonly SignInUserNames _userNames; + private readonly Action? _writeEvent; + private volatile bool _eventLogOff; + + /// Reports under the host's application name, writing names as says. + /// Supplies the application name written with each event. + /// Creates the logger. + /// How names are written (). + public SignInReporter(IHostEnvironment host, ILoggerFactory loggers, IOptions options) + : this(host, loggers, options, null) + { + } + + /// Supplies the application name written with each event. + /// Creates the logger. + /// How names are written. + /// Replaces the event-log write (tests): event id, whether it is a warning, the values. Null writes to the real event log. + internal SignInReporter(IHostEnvironment host, ILoggerFactory loggers, IOptions options, Action? writeEvent) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(loggers); + ArgumentNullException.ThrowIfNull(options); + _appName = host.ApplicationName; + _logger = loggers.CreateLogger(SignInEventFormat.LogCategory); + _userNames = options.Value.UserNames; + _writeEvent = writeEvent; + } + + /// False once an event-log write has failed; it stays so until the app restarts. + public bool EventLogOn => !_eventLogOff; + + /// + public void Report(SignInAttempt attempt) + { + ArgumentNullException.ThrowIfNull(attempt); + + // Information, not Warning: on Windows ASP.NET Core's default event-log logger takes Warning and + // up, so a Warning here would add a second Application-log entry per attempt during an attack. + _logger.LogInformation( + new EventId(attempt.Succeeded ? SignInEventFormat.SucceededId : SignInEventFormat.FailedId, + attempt.Succeeded ? "SignInSucceeded" : "SignInFailed"), + "Sign-in {Outcome} for {UserName} from {ClientIp} on {App} via {Path} ({Reason})", + attempt.Succeeded ? "succeeded" : "failed", SignInEventFormat.UserNameFor(attempt, _userNames), attempt.ClientIp, _appName, attempt.Path, + attempt.Succeeded ? "-" : attempt.Reason.ToString()); + + // No address, nothing to ban: PortGuardian has no use for the event. + if (_eventLogOff || attempt.ClientIp is null) + return; + if (_writeEvent is null && !OperatingSystem.IsWindows()) + return; + + WriteEvent(attempt, attempt.ClientIp.ToString()); + } + + private void WriteEvent(SignInAttempt attempt, string ip) + { + var id = attempt.Succeeded ? SignInEventFormat.SucceededId : SignInEventFormat.FailedId; + var warning = !attempt.Succeeded; + var values = SignInEventFormat.Values(attempt, ip, _appName, _userNames); + try + { + if (_writeEvent is not null) + _writeEvent(id, warning, values); + else if (OperatingSystem.IsWindows()) + WriteToEventLog(id, warning, values); + } +#pragma warning disable CA1031 // Any failure here must stay out of the sign-in. + catch (Exception ex) +#pragma warning restore CA1031 + { + // Almost always the source isn't registered (PortGuardian not installed) and an app pool may + // not create one. Say so once and stop, rather than throwing on every attempt of an attack. + _eventLogOff = true; + _logger.LogWarning(ex, + "Sign-in events are off until the app restarts: could not write to the event log as {Source}. " + + "PortGuardian's installer registers it; without PortGuardian nothing reads them.", + SignInEventFormat.Source); + } + } + + [SupportedOSPlatform("windows")] + private static void WriteToEventLog(int id, bool warning, string[] values) => + EventLog.WriteEvent(SignInEventFormat.Source, + new EventInstance(id, 0, warning ? EventLogEntryType.Warning : EventLogEntryType.Information), values); +} diff --git a/DapperIdentity.Tests/CustomSignInManagerTests.cs b/DapperIdentity.Tests/CustomSignInManagerTests.cs new file mode 100644 index 0000000..842f3b8 --- /dev/null +++ b/DapperIdentity.Tests/CustomSignInManagerTests.cs @@ -0,0 +1,167 @@ +using System.Net; +using CPE.DapperIdentity.Stores; +using CPE.DapperIdentity.Stores.Models; +using CPE.DapperIdentity.Stores.SignIn; +using DapperRepository; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; + +namespace DapperIdentity.Tests; + +/// +/// The sign-in manager as the JWT package wires it, over the real SQLite schema: every attempt is +/// reported, and a token sign-in refuses exactly whom a cookie sign-in refuses. +/// +/// +/// Before this, the JWT sign-in checked only the password hash (UserManager.CheckPasswordAsync), so a +/// user with IsEnabled = false still got a token. These tests are the proof that it no longer does. +/// +public sealed class CustomSignInManagerTests : IClassFixture +{ + private const string Password = "correct-horse"; + private readonly SqliteSchemaFixture _fixture; + + public CustomSignInManagerTests(SqliteSchemaFixture fixture) => _fixture = fixture; + + private sealed class Harness : IDisposable + { + private readonly ServiceProvider _root; + private readonly IServiceScope _scope; + + public Harness(string connectionString) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Reports); + services.AddDbConnectionInstantiatorForRepositories(connectionString); + services.AddJwtIdentity(SignInReportingTests.JwtConfig()); + _root = services.BuildServiceProvider(); + _scope = _root.CreateScope(); + + var http = new DefaultHttpContext { RequestServices = _scope.ServiceProvider }; + http.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.9"); + _scope.ServiceProvider.GetRequiredService().HttpContext = http; + } + + public SignInReportingTests.RecordingReporter Reports { get; } = new(); + public CustomSignInManager Manager => _scope.ServiceProvider.GetRequiredService(); + public UserManager Users => _scope.ServiceProvider.GetRequiredService>(); + + public async Task CreateAsync(bool enabled = true) + { + var name = $"user-{Guid.NewGuid():N}"; + var user = new CustomIdentityUser { UserName = name, Email = $"{name}@example.test", IsEnabled = enabled }; + var created = await Users.CreateAsync(user, Password); + Assert.True(created.Succeeded, string.Join("; ", created.Errors.Select(e => e.Description))); + return user; + } + + public void Dispose() + { + _scope.Dispose(); + _root.Dispose(); + } + } + + [Fact] + public async Task A_token_sign_in_with_the_right_password_returns_the_user_and_reports_a_success() + { + using var h = new Harness(_fixture.ConnectionString); + var user = await h.CreateAsync(); + + var (result, signedIn) = await h.Manager.CheckPasswordByEmailAsync(user.Email!, Password); + + Assert.True(result.Succeeded); + Assert.Equal(user.Id, signedIn?.Id); + var report = Assert.Single(h.Reports.Attempts); + Assert.Equal((true, "jwt", user.Email, IPAddress.Parse("203.0.113.9")), (report.Succeeded, report.Path, report.UserName, report.ClientIp)); + } + + [Fact] + public async Task A_disabled_user_gets_no_token_even_with_the_right_password() + { + using var h = new Harness(_fixture.ConnectionString); + var user = await h.CreateAsync(enabled: false); + + var (result, signedIn) = await h.Manager.CheckPasswordByEmailAsync(user.Email!, Password); + + Assert.True(result.IsNotAllowed); + Assert.Null(signedIn); + var report = Assert.Single(h.Reports.Attempts); + Assert.False(report.Succeeded); + } + + [Fact] + public async Task A_wrong_password_and_an_unknown_email_are_reported_as_what_they_are() + { + using var h = new Harness(_fixture.ConnectionString); + var user = await h.CreateAsync(); + + var (wrong, noUser) = await h.Manager.CheckPasswordByEmailAsync(user.Email!, "not-it"); + var (unknown, nobody) = await h.Manager.CheckPasswordByEmailAsync("nobody@example.test", Password); + + Assert.False(wrong.Succeeded); + Assert.False(unknown.Succeeded); + Assert.Null(noUser); + Assert.Null(nobody); + Assert.Equal([SignInFailure.BadPassword, SignInFailure.UnknownUser], h.Reports.Attempts.Select(a => a.Reason)); + Assert.All(h.Reports.Attempts, a => Assert.False(a.Succeeded)); + } + + [Fact] + public async Task A_cookie_sign_in_by_name_reports_an_unknown_name_and_a_wrong_password() + { + using var h = new Harness(_fixture.ConnectionString); + var user = await h.CreateAsync(); + + await h.Manager.PasswordSignInAsync("nobody", Password, isPersistent: false, lockoutOnFailure: false); + await h.Manager.PasswordSignInAsync(user.UserName!, "not-it", isPersistent: false, lockoutOnFailure: false); + + Assert.Equal( + [(SignInFailure.UnknownUser, "nobody"), (SignInFailure.BadPassword, user.UserName)], + h.Reports.Attempts.Select(a => (a.Reason, a.UserName))); + Assert.All(h.Reports.Attempts, a => Assert.Equal("cookie", a.Path)); + } + + [Fact] + public async Task A_refresh_is_refused_once_the_user_is_disabled() + { + using var h = new Harness(_fixture.ConnectionString); + var user = await h.CreateAsync(); + Assert.True(await h.Manager.AllowsSignInAsync(user)); + + user.IsEnabled = false; + + Assert.False(await h.Manager.AllowsSignInAsync(user)); + Assert.Empty(h.Reports.Attempts); // a refresh is not a password attempt + } + + [Fact] + public async Task A_reporter_that_throws_does_not_break_the_sign_in() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new ThrowingReporter()); + services.AddDbConnectionInstantiatorForRepositories(_fixture.ConnectionString); + services.AddJwtIdentity(SignInReportingTests.JwtConfig()); + using var root = services.BuildServiceProvider(); + using var scope = root.CreateScope(); + var users = scope.ServiceProvider.GetRequiredService>(); + var name = $"user-{Guid.NewGuid():N}"; + var user = new CustomIdentityUser { UserName = name, Email = $"{name}@example.test", IsEnabled = true }; + Assert.True((await users.CreateAsync(user, Password)).Succeeded); + + var (result, signedIn) = await scope.ServiceProvider.GetRequiredService() + .CheckPasswordByEmailAsync(user.Email!, Password); + + Assert.True(result.Succeeded); + Assert.NotNull(signedIn); + } + + private sealed class ThrowingReporter : ISignInReporter + { + public void Report(SignInAttempt attempt) => throw new InvalidOperationException("reporter down"); + } +} diff --git a/DapperIdentity.Tests/DapperIdentity.Tests.csproj b/DapperIdentity.Tests/DapperIdentity.Tests.csproj index b5bc9d7..dbddad8 100644 --- a/DapperIdentity.Tests/DapperIdentity.Tests.csproj +++ b/DapperIdentity.Tests/DapperIdentity.Tests.csproj @@ -22,6 +22,7 @@ + \ No newline at end of file diff --git a/DapperIdentity.Tests/JwtAuthControllerTests.cs b/DapperIdentity.Tests/JwtAuthControllerTests.cs new file mode 100644 index 0000000..4edf949 --- /dev/null +++ b/DapperIdentity.Tests/JwtAuthControllerTests.cs @@ -0,0 +1,151 @@ +using System.Net; +using CPE.DapperIdentity.Abstractions; +using CPE.DapperIdentity.Abstractions.Models; +using CPE.DapperIdentity.Jwt.Server; +using CPE.DapperIdentity.Jwt.Server.Controllers; +using CPE.DapperIdentity.Stores.Models; +using CPE.DapperIdentity.Stores.SignIn; +using DapperRepository; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace DapperIdentity.Tests; + +/// +/// The JWT endpoints themselves, wired as AddJwtIdentity wires them, over the real SQLite schema. +/// +/// +/// The sign-in manager's tests prove the manager refuses a disabled user; these prove the endpoints +/// ask it. Until 2026-09-26 Authenticate checked only the password hash and Refresh +/// checked nothing about the account, so a disabled user got and kept a token. +/// +public sealed class JwtAuthControllerTests : IClassFixture +{ + private const string Password = "correct-horse"; + private readonly SqliteSchemaFixture _fixture; + + public JwtAuthControllerTests(SqliteSchemaFixture fixture) => _fixture = fixture; + + private sealed class Harness : IDisposable + { + private readonly ServiceProvider _root; + private readonly IServiceScope _scope; + + public Harness(string connectionString) + { + var configuration = SignInReportingTests.JwtConfig(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(configuration); + services.AddSingleton(Reports); + services.AddSingleton(new NoMail()); + services.AddIAppSettings(new TestAppSettings()); + services.AddDbConnectionInstantiatorForRepositories(connectionString); + services.AddJwtIdentity(configuration); + _root = services.BuildServiceProvider(); + _scope = _root.CreateScope(); + + var http = new DefaultHttpContext { RequestServices = _scope.ServiceProvider }; + http.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.9"); + _scope.ServiceProvider.GetRequiredService().HttpContext = http; + + Controller = ActivatorUtilities.CreateInstance(_scope.ServiceProvider); + Controller.ControllerContext = new ControllerContext { HttpContext = http }; + } + + public SignInReportingTests.RecordingReporter Reports { get; } = new(); + public JwtAuthController Controller { get; } + public UserManager Users => _scope.ServiceProvider.GetRequiredService>(); + + public async Task CreateAsync(bool enabled = true) + { + var name = $"user-{Guid.NewGuid():N}"; + var user = new CustomIdentityUser { UserName = name, Email = $"{name}@example.test", IsEnabled = enabled }; + var created = await Users.CreateAsync(user, Password); + Assert.True(created.Succeeded, string.Join("; ", created.Errors.Select(e => e.Description))); + return user; + } + + public void Dispose() + { + _scope.Dispose(); + _root.Dispose(); + } + } + + [Fact] + public async Task The_right_password_gets_a_token_and_is_reported() + { + using var h = new Harness(_fixture.ConnectionString); + var user = await h.CreateAsync(); + + var answer = await h.Controller.Authenticate(new AuthRequest { Email = user.Email, Password = Password }); + + var ok = Assert.IsType(answer.Result); + Assert.False(string.IsNullOrEmpty(Assert.IsType(ok.Value).Token)); + var report = Assert.Single(h.Reports.Attempts); + Assert.Equal((true, "jwt"), (report.Succeeded, report.Path)); + } + + [Fact] + public async Task A_disabled_user_with_the_right_password_gets_no_token() + { + using var h = new Harness(_fixture.ConnectionString); + var user = await h.CreateAsync(enabled: false); + + var answer = await h.Controller.Authenticate(new AuthRequest { Email = user.Email, Password = Password }); + + // The same answer as a wrong password, so the reply does not reveal that the account exists. + Assert.Equal("Bad credentials", Assert.IsType(answer.Result).Value); + Assert.False(Assert.Single(h.Reports.Attempts).Succeeded); + } + + [Fact] + public async Task A_wrong_password_and_an_unknown_email_get_the_same_answer_and_are_reported() + { + using var h = new Harness(_fixture.ConnectionString); + var user = await h.CreateAsync(); + + var wrong = await h.Controller.Authenticate(new AuthRequest { Email = user.Email, Password = "not-it" }); + var unknown = await h.Controller.Authenticate(new AuthRequest { Email = "nobody@example.test", Password = Password }); + + Assert.Equal("Bad credentials", Assert.IsType(wrong.Result).Value); + Assert.Equal("Bad credentials", Assert.IsType(unknown.Result).Value); + Assert.Equal([SignInFailure.BadPassword, SignInFailure.UnknownUser], h.Reports.Attempts.Select(a => a.Reason)); + } + + [Fact] + public async Task A_refresh_works_while_the_user_is_enabled_and_stops_once_disabled() + { + using var h = new Harness(_fixture.ConnectionString); + var user = await h.CreateAsync(); + var first = (AuthResponse)Assert.IsType( + (await h.Controller.Authenticate(new AuthRequest { Email = user.Email, Password = Password })).Result).Value!; + + // The control: without it, a refresh broken for any reason would pass the refusal below. + var renewed = (AuthResponse)Assert.IsType( + (await h.Controller.Refresh(new RefreshTokenDto { Token = first.Token, RefreshToken = first.RefreshToken })).Result).Value!; + + var stored = await h.Users.FindByIdAsync(user.Id!); + stored!.IsEnabled = false; + Assert.True((await h.Users.UpdateAsync(stored)).Succeeded); + + var refused = await h.Controller.Refresh(new RefreshTokenDto { Token = renewed.Token, RefreshToken = renewed.RefreshToken }); + + Assert.IsType(refused.Result); + } + + private sealed class NoMail : IAuthEmailSender + { + public Task SendEmailAsync(string email, string subject, string htmlMessage) => Task.CompletedTask; + } + + private sealed class TestAppSettings : IAppSettings + { + public string ApplicationName => "TestApp"; + } +} diff --git a/DapperIdentity.Tests/SignInReportingTests.cs b/DapperIdentity.Tests/SignInReportingTests.cs new file mode 100644 index 0000000..a54b50e --- /dev/null +++ b/DapperIdentity.Tests/SignInReportingTests.cs @@ -0,0 +1,333 @@ +using System.Net; +using CPE.DapperIdentity.Jwt.Server; +using CPE.DapperIdentity.Stores.SignIn; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace DapperIdentity.Tests; + +/// +/// Pins the sign-in events PortGuardian reads (its D-067): the positional values it parses, what each +/// Identity outcome is reported as, and that reporting can never break a sign-in. +/// +public class SignInReportingTests +{ + private static HttpContext From(string address) + { + var http = new DefaultHttpContext(); + http.Connection.RemoteIpAddress = IPAddress.Parse(address); + return http; + } + + private const string BobHashed = "sha256:81b637d8fcd2c6da6359e6963113a1170de795e4b725b84d1e0b4cfd9ec58ce9"; + + [Fact] + public void A_failure_is_written_as_seven_positional_values() + { + var attempt = SignInAttempt.Failure(From("203.0.113.9"), "bob", SignInFailure.BadPassword, "jwt"); + + var values = SignInEventFormat.Values(attempt, "203.0.113.9", "BlazorHenemader", SignInUserNames.Plain); + + Assert.Equal( + ["Sign-in failed for 'bob' from 203.0.113.9 on BlazorHenemader (jwt): BadPassword.", + "1", "203.0.113.9", "bob", "BadPassword", "BlazorHenemader", "jwt"], + values); + } + + [Theory] + [InlineData(SignInUserNames.Hashed)] + [InlineData(SignInUserNames.Plain)] + public void A_success_never_carries_the_name_or_a_reason(SignInUserNames userNames) + { + // A success is reported only to protect its address; the customer's name or email is not needed for that. + var attempt = SignInAttempt.Success(From("203.0.113.9"), "bob@example.test", "cookie"); + + var values = SignInEventFormat.Values(attempt, "203.0.113.9", "App", userNames); + + Assert.Equal("", values[3]); + Assert.Equal("", values[4]); + Assert.DoesNotContain("bob", values[0], StringComparison.Ordinal); + Assert.StartsWith("Sign-in succeeded", values[0], StringComparison.Ordinal); + } + + [Fact] + public void By_default_a_failed_name_is_written_hashed_and_never_in_clear() + { + var attempt = SignInAttempt.Failure(From("203.0.113.9"), "bob", SignInFailure.BadPassword, "jwt"); + + var values = SignInEventFormat.Values(attempt, "203.0.113.9", "App"); + + Assert.Equal(BobHashed, values[3]); + Assert.All(values, v => Assert.DoesNotContain("'bob'", v, StringComparison.Ordinal)); + } + + [Theory] + [InlineData("bob")] + [InlineData(" BOB ")] + [InlineData("Bob\r\n")] + public void The_hash_is_of_the_trimmed_lower_cased_name_so_PortGuardian_can_match_its_own(string typed) + { + // The contract PortGuardian relies on to recognise the owner's names without seeing them: SHA-256 of the + // trimmed, lower-cased, cleaned name, lower-case hex, "sha256:" in front. + Assert.Equal(BobHashed, SignInEventFormat.Hash(typed)); + } + + [Fact] + public void None_writes_no_name_and_an_empty_name_hashes_to_nothing() + { + var attempt = SignInAttempt.Failure(From("203.0.113.9"), "bob", SignInFailure.BadPassword, "jwt"); + + Assert.Equal("", SignInEventFormat.Values(attempt, "203.0.113.9", "App", SignInUserNames.None)[3]); + Assert.Equal("", SignInEventFormat.Hash(" ")); + } + + [Fact] + public void The_name_setting_is_read_from_configuration_and_hashed_without_it() + { + var configured = new ServiceCollection(); + configured.AddSingleton(new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["DapperIdentity:SignInReporting:UserNames"] = "Plain" }) + .Build()); + configured.TryAddSignInReporter(); + + var bare = new ServiceCollection(); + bare.TryAddSignInReporter(); + + Assert.Equal(SignInUserNames.Plain, configured.BuildServiceProvider().GetRequiredService>().Value.UserNames); + Assert.Equal(SignInUserNames.Hashed, bare.BuildServiceProvider().GetRequiredService>().Value.UserNames); + } + + [Fact] + public void A_username_holding_line_breaks_cannot_forge_a_second_log_entry() + { + const string forged = "bob\r\n2026-09-26 12:00:00 Sign-in succeeded for admin"; + var logs = new CapturingLogs(); + var written = new List(); + var reporter = Reporter(logs, (_, _, values) => written.Add(values), SignInUserNames.Plain); + + reporter.Report(SignInAttempt.Failure(From("203.0.113.9"), forged, SignInFailure.UnknownUser, "jwt")); + + var message = Assert.Single(logs.Entries).Message; + Assert.DoesNotContain('\n', message); + Assert.DoesNotContain('\r', message); + Assert.All(Assert.Single(written), v => Assert.DoesNotContain('\n', v)); + Assert.Equal("bob2026-09-26 12:00:00 Sign-in succeeded for admin", written[0][3]); + } + + [Fact] + public void A_username_is_cut_to_a_length_a_log_can_hold_and_control_characters_go() + { + Assert.Equal(SignInEventFormat.MaxUserNameLength, SignInEventFormat.Clean(new string('a', 10_000)).Length); + Assert.Equal("ab", SignInEventFormat.Clean("a\u0000\tb")); + Assert.Equal("", SignInEventFormat.Clean(null)); + } + + [Fact] + public void An_ipv4_client_seen_through_a_dual_stack_socket_is_reported_as_ipv4() + { + var attempt = SignInAttempt.Failure(From("::ffff:203.0.113.9"), "bob", SignInFailure.Unspecified, "cookie"); + + Assert.Equal(IPAddress.Parse("203.0.113.9"), attempt.ClientIp); + } + + [Fact] + public void A_real_ipv6_client_is_kept_as_it_is() + { + var attempt = SignInAttempt.Failure(From("2001:db8::7"), "bob", SignInFailure.Unspecified, "cookie"); + + Assert.Equal(IPAddress.Parse("2001:db8::7"), attempt.ClientIp); + } + + public static TheoryData SignInOutcomes => new() + { + { nameof(SignInResult.Success), true, SignInFailure.Unspecified }, + // Only asked for a user who exists, so Failed is a wrong password. + { nameof(SignInResult.Failed), false, SignInFailure.BadPassword }, + { nameof(SignInResult.LockedOut), false, SignInFailure.LockedOut }, + // Identity answers NotAllowed before it checks the password, so it is a failed try. + { nameof(SignInResult.NotAllowed), false, SignInFailure.Unspecified }, + // The password was right and the second step is still to come: nothing to report yet. + { nameof(SignInResult.TwoFactorRequired), null, SignInFailure.Unspecified }, + }; + + [Theory] + [MemberData(nameof(SignInOutcomes))] + public void Each_sign_in_outcome_is_reported_as_what_it_means(string outcome, bool? succeeded, SignInFailure reason) + { + var result = outcome switch + { + nameof(SignInResult.Success) => SignInResult.Success, + nameof(SignInResult.Failed) => SignInResult.Failed, + nameof(SignInResult.LockedOut) => SignInResult.LockedOut, + nameof(SignInResult.NotAllowed) => SignInResult.NotAllowed, + _ => SignInResult.TwoFactorRequired, + }; + + var attempt = SignInAttempt.FromSignInResult(result, From("203.0.113.9"), "bob", "cookie"); + + if (succeeded is null) + { + Assert.Null(attempt); + return; + } + Assert.NotNull(attempt); + Assert.Equal((succeeded.Value, reason, "bob", "cookie"), (attempt.Succeeded, attempt.Reason, attempt.UserName, attempt.Path)); + } + + [Fact] + public void A_failure_is_one_warning_event_1000_and_a_success_one_information_event_1001() + { + var written = new List<(int Id, bool Warning, string[] Values)>(); + var reporter = Reporter(new CapturingLogs(), (id, warning, values) => written.Add((id, warning, values))); + + reporter.Report(SignInAttempt.Failure(From("203.0.113.9"), "bob", SignInFailure.BadPassword, "jwt")); + reporter.Report(SignInAttempt.Success(From("203.0.113.9"), "bob", "jwt")); + + Assert.Equal(2, written.Count); + Assert.Equal((1000, true), (written[0].Id, written[0].Warning)); + Assert.Equal((1001, false), (written[1].Id, written[1].Warning)); + Assert.Equal("TestApp", written[0].Values[5]); + Assert.Equal("PortGuardian.SignIn", SignInEventFormat.Source); // the name PortGuardian subscribes to + } + + [Fact] + public void Every_attempt_is_logged_at_information_under_its_own_category() + { + var logs = new CapturingLogs(); + var reporter = Reporter(logs, (_, _, _) => { }); + + reporter.Report(SignInAttempt.Failure(From("203.0.113.9"), "bob", SignInFailure.BadPassword, "jwt")); + + var entry = Assert.Single(logs.Entries); + Assert.Equal(("CPE.DapperIdentity.SignIn", LogLevel.Information, 1000), (entry.Category, entry.Level, entry.EventId.Id)); + Assert.Contains("203.0.113.9", entry.Message, StringComparison.Ordinal); + Assert.Contains(BobHashed, entry.Message, StringComparison.Ordinal); + Assert.DoesNotContain("bob ", entry.Message, StringComparison.Ordinal); + } + + [Fact] + public void An_attempt_without_an_address_is_logged_but_not_written_as_an_event() + { + var logs = new CapturingLogs(); + var written = 0; + var reporter = Reporter(logs, (_, _, _) => written++); + + reporter.Report(SignInAttempt.Failure(new DefaultHttpContext(), "bob", SignInFailure.BadPassword, "jwt")); + + Assert.Equal(0, written); + Assert.Single(logs.Entries); + } + + [Fact] + public void A_failing_event_log_never_reaches_the_sign_in_and_is_not_tried_again() + { + var logs = new CapturingLogs(); + var tries = 0; + var reporter = Reporter(logs, (_, _, _) => + { + tries++; + throw new System.Security.SecurityException("The source was not found, but some or all event logs could not be searched."); + }); + + var attempt = SignInAttempt.Failure(From("203.0.113.9"), "bob", SignInFailure.BadPassword, "jwt"); + reporter.Report(attempt); + reporter.Report(attempt); + + Assert.Equal(1, tries); + Assert.False(reporter.EventLogOn); + Assert.Single(logs.Entries, e => e.Level == LogLevel.Warning); + Assert.Equal(2, logs.Entries.Count(e => e.Level == LogLevel.Information)); + } + + [Fact] + public void The_default_reporter_is_registered_once_and_a_hosts_own_is_kept() + { + var mine = new RecordingReporter(); + var services = new ServiceCollection(); + services.AddSingleton(mine); + services.TryAddSignInReporter(); + Assert.Same(mine, services.BuildServiceProvider().GetRequiredService()); + + var plain = new ServiceCollection(); + plain.AddLogging(); + plain.AddSingleton(new TestHost()); + plain.TryAddSignInReporter(); + plain.TryAddSignInReporter(); + Assert.Single(plain, d => d.ServiceType == typeof(ISignInReporter)); + Assert.IsType(plain.BuildServiceProvider().GetRequiredService()); + } + + public static TheoryData SetUps => new() { "jwt", "cookies", "vanilla-ui" }; + + [Theory] + [MemberData(nameof(SetUps))] + public void Every_set_up_signs_in_through_the_custom_manager_and_registers_a_reporter(string setUp) + { + var services = new ServiceCollection(); + services.AddLogging(); + _ = setUp switch + { + "jwt" => services.AddJwtIdentity(JwtConfig()), + "cookies" => services.AddDapperIdentityWithCustomCookies(TimeSpan.FromDays(1)), + _ => services.AddDapperIdentityWithVanillaUIAndDefaults(TimeSpan.FromDays(1)), + }; + + // The custom manager is what refuses a disabled user and what reports; the stock one does neither. + // AddIdentity registers the stock one first; the last registration is the one resolved. + var manager = services.Last(d => d.ServiceType == typeof(SignInManager)); + Assert.Equal(typeof(CPE.DapperIdentity.Stores.CustomSignInManager), manager.ImplementationType); + Assert.Contains(services, d => d.ServiceType == typeof(ISignInReporter)); + } + + private static SignInReporter Reporter(CapturingLogs logs, Action writeEvent, SignInUserNames userNames = SignInUserNames.Hashed) => + new(new TestHost(), logs, Options.Create(new SignInReportingOptions { UserNames = userNames }), writeEvent); + + internal static IConfiguration JwtConfig() => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + [AppBaseUrl.ConfigurationKey] = "https://app.example.test", + ["JwtTokenSettings:ValidIssuer"] = "test-issuer", + ["JwtTokenSettings:ValidAudience"] = "test-audience", + ["JwtTokenSettings:SymmetricSecurityKey"] = "a-test-signing-key-that-is-long-enough-for-hmac", + ["JwtTokenSettings:JwtExpireSeconds"] = "900", + ["JwtTokenSettings:RefreshTokenLifeDays"] = "4", + }).Build(); + + internal sealed class RecordingReporter : ISignInReporter + { + public List Attempts { get; } = []; + public void Report(SignInAttempt attempt) => Attempts.Add(attempt); + } + + private sealed class TestHost : IHostEnvironment + { + public string EnvironmentName { get; set; } = "Test"; + public string ApplicationName { get; set; } = "TestApp"; + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } + + private sealed record LogEntry(string Category, LogLevel Level, EventId EventId, string Message); + + /// A logger factory that keeps every entry, so the log line itself can be checked. + private sealed class CapturingLogs : ILoggerFactory + { + public List Entries { get; } = []; + public ILogger CreateLogger(string categoryName) => new Logger(categoryName, Entries); + public void AddProvider(ILoggerProvider provider) { } + public void Dispose() { } + + private sealed class Logger(string category, List entries) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + entries.Add(new LogEntry(category, logLevel, eventId, formatter(state, exception))); + } + } +}