< Summary

Information
Class: Anichron.API.Services.AuthResult
Assembly: Anichron.API
File(s): /home/runner/work/anichron/anichron/src/Anichron.API/Services/AuthService.cs
Tag: 228_36821334185
Line coverage
100%
Covered lines: 10
Uncovered lines: 0
Coverable lines: 10
Total lines: 256
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_IsSuccess()100%11100%
Ok()100%11100%
Ok(...)100%11100%
Fail(...)100%11100%
Fail(...)100%11100%
Locked(...)100%11100%

File(s)

/home/runner/work/anichron/anichron/src/Anichron.API/Services/AuthService.cs

#LineLine coverage
 1using Anichron.API.Security;
 2using Anichron.Core.Data;
 3using Anichron.Core.Data.Repository;
 4using Anichron.Core.Domain;
 5using Microsoft.EntityFrameworkCore;
 6using Npgsql;
 7using System.Diagnostics;
 8using System.Security.Cryptography;
 9using System.Text;
 10
 11namespace Anichron.API.Services;
 12
 13public sealed record AuthTokens(string AccessToken, string RefreshToken);
 14public sealed record AdminCreatedUser(Guid Id, string Username, string Email, string TemporaryPassword);
 15
 16public sealed record AuthResult<T>
 17{
 18    public T? Value { get; init; }
 19    public AuthError? Error { get; init; }
 20    public bool IsSuccess => Error is null;
 21    public int? RetryAfterSeconds { get; init; }
 22}
 23
 24public sealed record AuthResult
 25{
 26    public AuthError? Error { get; init; }
 1927    public bool IsSuccess => Error is null;
 28    public int? RetryAfterSeconds { get; init; }
 29
 1230    public static AuthResult Ok() => new();
 4131    public static AuthResult<T> Ok<T>(T value) => new() { Value = value };
 32
 2333    public static AuthResult Fail(AuthError error) => new() { Error = error };
 6834    public static AuthResult<T> Fail<T>(AuthError error) => new() { Error = error };
 35
 536    public static AuthResult<T> Locked<T>(int retryAfterSeconds) => new()
 537    {
 538        Error = AuthError.AccountTemporarilyLocked,
 539        RetryAfterSeconds = retryAfterSeconds,
 540    };
 41}
 42
 43public interface IAuthService
 44{
 45    Task<AuthResult<AuthTokens>> RegisterAsync(string username, string email, string password, string inviteToken, Cance
 46    Task<AuthResult<AuthTokens>> LoginAsync(string usernameOrEmail, string password, CancellationToken ct);
 47    Task<AuthResult<AuthTokens>> RefreshAsync(string rawToken, CancellationToken ct);
 48    Task RevokeAsync(string rawToken, CancellationToken ct);
 49    Task<AuthResult> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, CancellationToken ct);
 50    Task<AuthResult<AdminCreatedUser>> AdminCreateUserAsync(string username, string email, CancellationToken ct);
 51}
 52
 53public sealed class AuthService(
 54    IUserRepository users,
 55    IInviteRepository invites,
 56    IUnitOfWork unitOfWork,
 57    IClock clock,
 58    IGuidFactory guidFactory,
 59    IPasswordHasher passwordHasher,
 60    IRegistrationValidator validator,
 61    ITokenService tokenService,
 62    ILockoutService lockout)
 63    : IAuthService
 64{
 65    public async Task<AuthResult<AuthTokens>> RegisterAsync(string username, string email, string password, string invit
 66    {
 67        ArgumentNullException.ThrowIfNull(username);
 68        ArgumentNullException.ThrowIfNull(email);
 69        ArgumentNullException.ThrowIfNull(password);
 70        ArgumentNullException.ThrowIfNull(inviteToken);
 71
 72        var now = clock.GetCurrentInstant();
 73        var invite = await invites.FindValidByHashAsync(HashInviteToken(inviteToken), now, ct);
 74        if (invite is null)
 75            return AuthResult.Fail<AuthTokens>(AuthError.InviteTokenInvalid);
 76
 77        var normalizedUsername = username.Trim().ToLowerInvariant();
 78        var normalizedEmail = email.Trim().ToLowerInvariant();
 79
 80        var error = await validator.ValidateAsync(normalizedUsername, normalizedEmail, password, ct);
 81        if (error is not null)
 82            return AuthResult.Fail<AuthTokens>(error.Value);
 83
 84        if (await users.AnyByUsernameAsync(normalizedUsername, ct))
 85            return AuthResult.Fail<AuthTokens>(AuthError.UsernameTaken);
 86
 87        if (await users.AnyByEmailAsync(normalizedEmail, ct))
 88            return AuthResult.Fail<AuthTokens>(AuthError.EmailTaken);
 89
 90        var user = new User
 91        {
 92            Id = guidFactory.NewGuid(),
 93            Username = normalizedUsername,
 94            Email = normalizedEmail,
 95            PasswordHash = passwordHasher.Hash(password),
 96        };
 97
 98        users.Add(user);
 99        invite.UsedAt = now;
 100        invite.UsedByUserId = user.Id;
 101
 102        try
 103        {
 104            return AuthResult.Ok(await unitOfWork.ExecuteInTransactionAsync(async () =>
 105            {
 106                await unitOfWork.SaveChangesAsync(ct);
 107                return await tokenService.IssueAsync(user, ct);
 108            }, ct));
 109        }
 110        catch (DbUpdateConcurrencyException)
 111        {
 112            return AuthResult.Fail<AuthTokens>(AuthError.InviteTokenInvalid);
 113        }
 114        catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" } postgresExceptio
 115        {
 116            return AuthResult.Fail<AuthTokens>(DetectConstraintError(postgresException));
 117        }
 118    }
 119
 120    internal static string HashInviteToken(string rawToken)
 121        => Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(rawToken)));
 122
 123    public async Task<AuthResult<AuthTokens>> LoginAsync(string usernameOrEmail, string password, CancellationToken ct)
 124    {
 125        ArgumentNullException.ThrowIfNull(usernameOrEmail);
 126        ArgumentNullException.ThrowIfNull(password);
 127
 128        var normalized = usernameOrEmail.Trim().ToLowerInvariant();
 129        var user = await users.FindByCredentialAsync(normalized, ct);
 130
 131        // A null hash means "no such account", and the hasher spends a full Argon2 pass on it
 132        // rather than short-circuiting. That removes the hashing asymmetry; this call site's only
 133        // job is not to defeat it by branching before the call.
 134        //
 135        // ⚠️ It does NOT make login constant-time end to end, and this comment deliberately does
 136        // not claim that. A known user with a wrong password goes on to
 137        // RecordFailedAttemptAsync below, which is a database write an unknown user never makes —
 138        // milliseconds, far more than the hashing difference this removes. Username enumeration
 139        // by timing is therefore still possible. Tracked separately; see the note on that call.
 140        var passwordValid = passwordHasher.Verify(password, user?.PasswordHash);
 141
 142        var now = clock.GetCurrentInstant();
 143
 144        // Record failed attempt only if not already locked
 145        // Prevents counter growth and redundant DB writes during an active lockout.
 146        if (user is not null && !passwordValid && !lockout.IsLockedOut(user, now))
 147            await lockout.RecordFailedAttemptAsync(user, now, ct);
 148
 149        if (user is null || !passwordValid)
 150            return AuthResult.Fail<AuthTokens>(AuthError.InvalidCredentials);
 151
 152        if (user.IsDisabled)
 153            return AuthResult.Fail<AuthTokens>(AuthError.AccountDisabled);
 154
 155        if (lockout.IsLockedOut(user, now))
 156        {
 157            // IsLockedOut guarantees LockedUntil is non-null and in the future.
 158            var secondsRemaining = (int)Math.Ceiling((user.LockedUntil!.Value - now).TotalSeconds);
 159            return AuthResult.Locked<AuthTokens>(Math.Max(1, secondsRemaining));
 160        }
 161
 162        // Counter reset and token issuance share one transaction — if IssueAsync fails,
 163        // the counter is not persisted so the user's lockout state is preserved correctly.
 164        lockout.PrepareReset(user);
 165
 166        var tokens = await unitOfWork.ExecuteInTransactionAsync(async () =>
 167        {
 168            await unitOfWork.SaveChangesAsync(ct);
 169            return await tokenService.IssueAsync(user, ct);
 170        }, ct);
 171
 172        return AuthResult.Ok(tokens);
 173    }
 174
 175    public Task<AuthResult<AuthTokens>> RefreshAsync(string rawToken, CancellationToken ct)
 176        => tokenService.RefreshAsync(rawToken, ct);
 177
 178    public Task RevokeAsync(string rawToken, CancellationToken ct)
 179        => tokenService.RevokeAsync(rawToken, ct);
 180
 181    public async Task<AuthResult> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, Cancellat
 182    {
 183        ArgumentNullException.ThrowIfNull(currentPassword);
 184        ArgumentNullException.ThrowIfNull(newPassword);
 185
 186        var user = await users.FindByIdAsync(userId, ct);
 187        if (user is null || !passwordHasher.Verify(currentPassword, user.PasswordHash))
 188            return AuthResult.Fail(AuthError.InvalidCredentials);
 189
 190        var error = await validator.ValidatePasswordAsync(newPassword, ct);
 191        if (error is not null)
 192            return AuthResult.Fail(error.Value);
 193
 194        await unitOfWork.ExecuteInTransactionAsync(async () =>
 195        {
 196            user.PasswordHash = passwordHasher.Hash(newPassword);
 197            user.MustChangePassword = false;
 198            var now = clock.GetCurrentInstant();
 199            await tokenService.MarkAllSessionsRevokedAsync(userId, now, ct);
 200            await unitOfWork.SaveChangesAsync(ct);
 201        }, ct);
 202
 203        return AuthResult.Ok();
 204    }
 205
 206    public async Task<AuthResult<AdminCreatedUser>> AdminCreateUserAsync(string username, string email, CancellationToke
 207    {
 208        ArgumentNullException.ThrowIfNull(username);
 209        ArgumentNullException.ThrowIfNull(email);
 210
 211        var normalizedUsername = username.Trim().ToLowerInvariant();
 212        var normalizedEmail = email.Trim().ToLowerInvariant();
 213
 214        var identityError = validator.ValidateIdentity(normalizedUsername, normalizedEmail);
 215        if (identityError is not null)
 216            return AuthResult.Fail<AdminCreatedUser>(identityError.Value);
 217
 218        if (await users.AnyByUsernameAsync(normalizedUsername, ct))
 219            return AuthResult.Fail<AdminCreatedUser>(AuthError.UsernameTaken);
 220
 221        if (await users.AnyByEmailAsync(normalizedEmail, ct))
 222            return AuthResult.Fail<AdminCreatedUser>(AuthError.EmailTaken);
 223
 224        var temporaryPassword = Convert.ToBase64String(RandomNumberGenerator.GetBytes(12));
 225
 226        var user = new User
 227        {
 228            Id = guidFactory.NewGuid(),
 229            Username = normalizedUsername,
 230            Email = normalizedEmail,
 231            PasswordHash = passwordHasher.Hash(temporaryPassword),
 232            MustChangePassword = true,
 233        };
 234
 235        users.Add(user);
 236
 237        try
 238        {
 239            await unitOfWork.SaveChangesAsync(ct);
 240        }
 241        catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" } postgresExceptio
 242        {
 243            return AuthResult.Fail<AdminCreatedUser>(DetectConstraintError(postgresException));
 244        }
 245
 246        return AuthResult.Ok(new AdminCreatedUser(user.Id, user.Username, user.Email, temporaryPassword));
 247    }
 248
 249    private static AuthError DetectConstraintError(PostgresException postgresException)
 250        => postgresException.ConstraintName switch
 251        {
 252            UserIndexNames.EmailUnique => AuthError.EmailTaken,
 253            UserIndexNames.UsernameUnique => AuthError.UsernameTaken,
 254            _ => throw new UnreachableException($"Unexpected unique constraint violation: {postgresException.ConstraintN
 255        }; // ix_users_username is the only other unique constraint on User
 256}