< Summary

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

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
RegisterAsync()100%88100%
HashInviteToken(...)100%11100%
LoginAsync()100%1616100%
RefreshAsync(...)100%11100%
RevokeAsync(...)100%11100%
ChangePasswordAsync()100%66100%
AdminCreateUserAsync()100%66100%
DetectConstraintError(...)100%44100%

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; }
 27    public bool IsSuccess => Error is null;
 28    public int? RetryAfterSeconds { get; init; }
 29
 30    public static AuthResult Ok() => new();
 31    public static AuthResult<T> Ok<T>(T value) => new() { Value = value };
 32
 33    public static AuthResult Fail(AuthError error) => new() { Error = error };
 34    public static AuthResult<T> Fail<T>(AuthError error) => new() { Error = error };
 35
 36    public static AuthResult<T> Locked<T>(int retryAfterSeconds) => new()
 37    {
 38        Error = AuthError.AccountTemporarilyLocked,
 39        RetryAfterSeconds = retryAfterSeconds,
 40    };
 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
 5953public sealed class AuthService(
 5954    IUserRepository users,
 5955    IInviteRepository invites,
 5956    IUnitOfWork unitOfWork,
 5957    IClock clock,
 5958    IGuidFactory guidFactory,
 5959    IPasswordHasher passwordHasher,
 5960    IRegistrationValidator validator,
 5961    ITokenService tokenService,
 5962    ILockoutService lockout)
 63    : IAuthService
 64{
 65    public async Task<AuthResult<AuthTokens>> RegisterAsync(string username, string email, string password, string invit
 2066    {
 2067        ArgumentNullException.ThrowIfNull(username);
 1968        ArgumentNullException.ThrowIfNull(email);
 1869        ArgumentNullException.ThrowIfNull(password);
 1770        ArgumentNullException.ThrowIfNull(inviteToken);
 71
 1672        var now = clock.GetCurrentInstant();
 1673        var invite = await invites.FindValidByHashAsync(HashInviteToken(inviteToken), now, ct);
 1674        if (invite is null)
 275            return AuthResult.Fail<AuthTokens>(AuthError.InviteTokenInvalid);
 76
 1477        var normalizedUsername = username.Trim().ToLowerInvariant();
 1478        var normalizedEmail = email.Trim().ToLowerInvariant();
 79
 1480        var error = await validator.ValidateAsync(normalizedUsername, normalizedEmail, password, ct);
 1481        if (error is not null)
 582            return AuthResult.Fail<AuthTokens>(error.Value);
 83
 984        if (await users.AnyByUsernameAsync(normalizedUsername, ct))
 285            return AuthResult.Fail<AuthTokens>(AuthError.UsernameTaken);
 86
 787        if (await users.AnyByEmailAsync(normalizedEmail, ct))
 188            return AuthResult.Fail<AuthTokens>(AuthError.EmailTaken);
 89
 690        var user = new User
 691        {
 692            Id = guidFactory.NewGuid(),
 693            Username = normalizedUsername,
 694            Email = normalizedEmail,
 695            PasswordHash = passwordHasher.Hash(password),
 696        };
 97
 698        users.Add(user);
 699        invite.UsedAt = now;
 6100        invite.UsedByUserId = user.Id;
 101
 102        try
 6103        {
 6104            return AuthResult.Ok(await unitOfWork.ExecuteInTransactionAsync(async () =>
 6105            {
 6106                await unitOfWork.SaveChangesAsync(ct);
 6107                return await tokenService.IssueAsync(user, ct);
 6108            }, ct));
 109        }
 1110        catch (DbUpdateConcurrencyException)
 1111        {
 1112            return AuthResult.Fail<AuthTokens>(AuthError.InviteTokenInvalid);
 113        }
 3114        catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" } postgresExceptio
 3115        {
 3116            return AuthResult.Fail<AuthTokens>(DetectConstraintError(postgresException));
 117        }
 15118    }
 119
 120    internal static string HashInviteToken(string rawToken)
 20121        => Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(rawToken)));
 122
 123    public async Task<AuthResult<AuthTokens>> LoginAsync(string usernameOrEmail, string password, CancellationToken ct)
 14124    {
 14125        ArgumentNullException.ThrowIfNull(usernameOrEmail);
 13126        ArgumentNullException.ThrowIfNull(password);
 127
 12128        var normalized = usernameOrEmail.Trim().ToLowerInvariant();
 12129        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.
 12140        var passwordValid = passwordHasher.Verify(password, user?.PasswordHash);
 141
 12142        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.
 12146        if (user is not null && !passwordValid && !lockout.IsLockedOut(user, now))
 2147            await lockout.RecordFailedAttemptAsync(user, now, ct);
 148
 12149        if (user is null || !passwordValid)
 6150            return AuthResult.Fail<AuthTokens>(AuthError.InvalidCredentials);
 151
 6152        if (user.IsDisabled)
 1153            return AuthResult.Fail<AuthTokens>(AuthError.AccountDisabled);
 154
 5155        if (lockout.IsLockedOut(user, now))
 1156        {
 157            // IsLockedOut guarantees LockedUntil is non-null and in the future.
 1158            var secondsRemaining = (int)Math.Ceiling((user.LockedUntil!.Value - now).TotalSeconds);
 1159            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.
 4164        lockout.PrepareReset(user);
 165
 4166        var tokens = await unitOfWork.ExecuteInTransactionAsync(async () =>
 4167        {
 4168            await unitOfWork.SaveChangesAsync(ct);
 4169            return await tokenService.IssueAsync(user, ct);
 4170        }, ct);
 171
 4172        return AuthResult.Ok(tokens);
 12173    }
 174
 175    public Task<AuthResult<AuthTokens>> RefreshAsync(string rawToken, CancellationToken ct)
 1176        => tokenService.RefreshAsync(rawToken, ct);
 177
 178    public Task RevokeAsync(string rawToken, CancellationToken ct)
 1179        => tokenService.RevokeAsync(rawToken, ct);
 180
 181    public async Task<AuthResult> ChangePasswordAsync(Guid userId, string currentPassword, string newPassword, Cancellat
 10182    {
 10183        ArgumentNullException.ThrowIfNull(currentPassword);
 9184        ArgumentNullException.ThrowIfNull(newPassword);
 185
 8186        var user = await users.FindByIdAsync(userId, ct);
 8187        if (user is null || !passwordHasher.Verify(currentPassword, user.PasswordHash))
 2188            return AuthResult.Fail(AuthError.InvalidCredentials);
 189
 6190        var error = await validator.ValidatePasswordAsync(newPassword, ct);
 6191        if (error is not null)
 3192            return AuthResult.Fail(error.Value);
 193
 3194        await unitOfWork.ExecuteInTransactionAsync(async () =>
 3195        {
 3196            user.PasswordHash = passwordHasher.Hash(newPassword);
 3197            user.MustChangePassword = false;
 3198            var now = clock.GetCurrentInstant();
 3199            await tokenService.MarkAllSessionsRevokedAsync(userId, now, ct);
 3200            await unitOfWork.SaveChangesAsync(ct);
 3201        }, ct);
 202
 3203        return AuthResult.Ok();
 8204    }
 205
 206    public async Task<AuthResult<AdminCreatedUser>> AdminCreateUserAsync(string username, string email, CancellationToke
 13207    {
 13208        ArgumentNullException.ThrowIfNull(username);
 12209        ArgumentNullException.ThrowIfNull(email);
 210
 11211        var normalizedUsername = username.Trim().ToLowerInvariant();
 11212        var normalizedEmail = email.Trim().ToLowerInvariant();
 213
 11214        var identityError = validator.ValidateIdentity(normalizedUsername, normalizedEmail);
 11215        if (identityError is not null)
 2216            return AuthResult.Fail<AdminCreatedUser>(identityError.Value);
 217
 9218        if (await users.AnyByUsernameAsync(normalizedUsername, ct))
 1219            return AuthResult.Fail<AdminCreatedUser>(AuthError.UsernameTaken);
 220
 8221        if (await users.AnyByEmailAsync(normalizedEmail, ct))
 1222            return AuthResult.Fail<AdminCreatedUser>(AuthError.EmailTaken);
 223
 7224        var temporaryPassword = Convert.ToBase64String(RandomNumberGenerator.GetBytes(12));
 225
 7226        var user = new User
 7227        {
 7228            Id = guidFactory.NewGuid(),
 7229            Username = normalizedUsername,
 7230            Email = normalizedEmail,
 7231            PasswordHash = passwordHasher.Hash(temporaryPassword),
 7232            MustChangePassword = true,
 7233        };
 234
 7235        users.Add(user);
 236
 237        try
 7238        {
 7239            await unitOfWork.SaveChangesAsync(ct);
 4240        }
 3241        catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" } postgresExceptio
 3242        {
 3243            return AuthResult.Fail<AdminCreatedUser>(DetectConstraintError(postgresException));
 244        }
 245
 4246        return AuthResult.Ok(new AdminCreatedUser(user.Id, user.Username, user.Email, temporaryPassword));
 10247    }
 248
 249    private static AuthError DetectConstraintError(PostgresException postgresException)
 6250        => postgresException.ConstraintName switch
 6251        {
 2252            UserIndexNames.EmailUnique => AuthError.EmailTaken,
 2253            UserIndexNames.UsernameUnique => AuthError.UsernameTaken,
 2254            _ => throw new UnreachableException($"Unexpected unique constraint violation: {postgresException.ConstraintN
 6255        }; // ix_users_username is the only other unique constraint on User
 256}