< Summary

Information
Class: Services.PartnerServices.PartnerAuthService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/PartnerServices/PartnerAuthService.cs
Line coverage
96%
Covered lines: 151
Uncovered lines: 5
Coverable lines: 156
Total lines: 310
Line coverage: 96.7%
Branch coverage
87%
Covered branches: 79
Total branches: 90
Branch coverage: 87.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
.cctor()100%11100%
LookupNameByEmailAsync()100%22100%
RequestCodeAsync()86.36%2222100%
SignInWithCodeAsync()93.33%3030100%
SendInviteAsync()100%1212100%
SignInWithGoogleAsync()62.5%9880%
ValidateSessionAsync()100%66100%
BuildInviteLink(...)100%11100%
IssueCodeAsync()83.33%66100%
HashCode(...)100%11100%
VerifyHash(...)50%2277.77%
CompleteLoginAsync()50%22100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/PartnerServices/PartnerAuthService.cs

#LineLine coverage
 1using System.Security.Cryptography;
 2using System.Text;
 3using Microsoft.EntityFrameworkCore;
 4using Microsoft.Extensions.Options;
 5using OzonDomains.Models.Partners;
 6using OzonRepositories.Context;
 7
 8namespace Services.PartnerServices;
 9
 10public enum PartnerLoginStatus
 11{
 12    Success,
 13    InvalidCredentials,
 14    Locked,
 15    Disabled
 16}
 17
 18public sealed record PartnerLoginResult(PartnerLoginStatus Status, Partner? Partner, DateTime? LockedUntilUtc = null);
 19
 20public enum PartnerCodeRequestStatus
 21{
 22    /// <summary>Код выпущен и письмо принято SMTP.</summary>
 23    Sent,
 24
 25    /// <summary>Такой почты нет или партнёр отключён: письмо не уходило.</summary>
 26    NotSent,
 27
 28    /// <summary>Предыдущий код выпущен меньше минуты назад.</summary>
 29    Throttled,
 30
 31    /// <summary>Вход заблокирован после неверных кодов.</summary>
 32    Locked,
 33
 34    /// <summary>Код выпущен, но письмо отправить не удалось.</summary>
 35    MailFailed
 36}
 37
 38public sealed record PartnerCodeRequestResult(PartnerCodeRequestStatus Status, int RetryAfterSeconds = 0, DateTime? Lock
 39
 40public interface IPartnerAuthService
 41{
 42    /// <summary>Название организации по точной почте активного партнёра, иначе null.</summary>
 43    Task<string?> LookupNameByEmailAsync(string email, CancellationToken ct = default);
 44
 45    /// <summary>Выпускает код входа и отправляет его на почту партнёра.</summary>
 46    Task<PartnerCodeRequestResult> RequestCodeAsync(string email, string? ip, CancellationToken ct = default);
 47
 48    /// <summary>Вход по коду из письма (код входа или код из приглашения).</summary>
 49    Task<PartnerLoginResult> SignInWithCodeAsync(string email, string code, string? ip, CancellationToken ct = default);
 50
 51    /// <summary>Отправляет партнёру приглашение со ссылкой и готовым кодом. Прежние приглашения перестают действовать.<
 52    Task<PartnerOpResult<bool>> SendInviteAsync(long partnerId, string ownerId, CancellationToken ct = default);
 53
 54    /// <summary>Вход через Google: пускает только партнёра, у которого эта почта заведена владельцем.</summary>
 55    Task<PartnerLoginResult> SignInWithGoogleAsync(string email, string? ip, CancellationToken ct = default);
 56
 57    /// <summary>Актуальный партнёр для проверки сессии: null, если удалён, отключён или сменился SecurityStamp.</summar
 58    Task<Partner?> ValidateSessionAsync(long partnerId, string securityStamp, CancellationToken ct = default);
 59}
 60
 4361public sealed class PartnerAuthService(
 4362    ApplicationDbContext db,
 4363    IPartnerAuditService audit,
 4364    IPartnerLoginMailer mailer,
 4365    IOptions<PartnerOfficeOptions> options) : IPartnerAuthService
 66{
 67    internal const int MaxFailedAttempts = 5;
 68    internal const int MaxCodeAttempts = 5;
 69    internal const int CodeLength = 6;
 170    internal static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15);
 171    internal static readonly TimeSpan LoginCodeLifetime = TimeSpan.FromMinutes(10);
 172    internal static readonly TimeSpan InviteLifetime = TimeSpan.FromDays(7);
 173    internal static readonly TimeSpan ResendPause = TimeSpan.FromSeconds(60);
 74
 75    public async Task<string?> LookupNameByEmailAsync(string email, CancellationToken ct = default)
 76    {
 577        var normalized = PartnerEmail.Normalize(email);
 778        if (!PartnerEmail.IsValid(normalized)) return null;
 79
 380        return await db.Partners.AsNoTracking()
 381            .Where(x => x.Email == normalized && x.IsActive)
 382            .Select(x => x.Name)
 383            .FirstOrDefaultAsync(ct);
 584    }
 85
 86    public async Task<PartnerCodeRequestResult> RequestCodeAsync(string email, string? ip, CancellationToken ct = defaul
 87    {
 1688        var normalized = PartnerEmail.Normalize(email);
 1689        var partner = PartnerEmail.IsValid(normalized)
 1690            ? await db.Partners.FirstOrDefaultAsync(x => x.Email == normalized, ct)
 1691            : null;
 92
 1693        if (partner is null || !partner.IsActive)
 94        {
 195            await audit.WriteAsync("code_request_failed", PartnerActorKind.Partner, normalized, partner?.Id, false,
 196                partner is null ? "Почта не заведена" : "Партнёр отключён", ipAddress: ip, ct: ct);
 197            return new PartnerCodeRequestResult(PartnerCodeRequestStatus.NotSent);
 98        }
 99
 15100        var now = DateTime.UtcNow;
 15101        if (partner.LockoutUntilUtc is { } until && until > now)
 1102            return new PartnerCodeRequestResult(PartnerCodeRequestStatus.Locked, LockedUntilUtc: until);
 103
 14104        var lastIssuedAt = await db.PartnerLoginCodes
 14105            .Where(x => x.PartnerId == partner.Id && x.Kind == PartnerLoginCodeKind.Login)
 14106            .OrderByDescending(x => x.CreatedAtUtc)
 14107            .Select(x => (DateTime?)x.CreatedAtUtc)
 14108            .FirstOrDefaultAsync(ct);
 14109        if (lastIssuedAt is { } last && now - last < ResendPause)
 110        {
 1111            var wait = (int)Math.Ceiling((ResendPause - (now - last)).TotalSeconds);
 1112            return new PartnerCodeRequestResult(PartnerCodeRequestStatus.Throttled, wait);
 113        }
 114
 13115        var code = await IssueCodeAsync(partner, PartnerLoginCodeKind.Login, LoginCodeLifetime, ip, ct);
 13116        var sent = await mailer.SendLoginCodeAsync(partner.Email!, partner.Name, code, ct);
 13117        await audit.WriteAsync("code_requested", PartnerActorKind.Partner, partner.Email!, partner.Id, sent,
 13118            sent ? "Код отправлен на почту" : "Письмо с кодом не отправлено", ipAddress: ip, ct: ct);
 13119        return new PartnerCodeRequestResult(sent ? PartnerCodeRequestStatus.Sent : PartnerCodeRequestStatus.MailFailed);
 16120    }
 121
 122    public async Task<PartnerLoginResult> SignInWithCodeAsync(
 123        string email, string code, string? ip, CancellationToken ct = default)
 124    {
 21125        var normalized = PartnerEmail.Normalize(email);
 21126        var partner = PartnerEmail.IsValid(normalized)
 21127            ? await db.Partners.FirstOrDefaultAsync(x => x.Email == normalized, ct)
 21128            : null;
 129
 21130        if (partner is null)
 131        {
 1132            await audit.WriteAsync("login_failed", PartnerActorKind.Partner, normalized, null, false,
 1133                "Почта не заведена", ipAddress: ip, ct: ct);
 1134            return new PartnerLoginResult(PartnerLoginStatus.InvalidCredentials, null);
 135        }
 136
 20137        if (!partner.IsActive)
 138        {
 1139            await audit.WriteAsync("login_failed", PartnerActorKind.Partner, partner.Email!, partner.Id, false,
 1140                "Партнёр отключён", ipAddress: ip, ct: ct);
 1141            return new PartnerLoginResult(PartnerLoginStatus.Disabled, null);
 142        }
 143
 19144        var now = DateTime.UtcNow;
 19145        if (partner.LockoutUntilUtc is { } until && until > now)
 146        {
 1147            await audit.WriteAsync("login_failed", PartnerActorKind.Partner, partner.Email!, partner.Id, false,
 1148                "Вход заблокирован после неверных кодов", ipAddress: ip, ct: ct);
 1149            return new PartnerLoginResult(PartnerLoginStatus.Locked, null, until);
 150        }
 151
 18152        var digits = new string((code ?? string.Empty).Where(char.IsDigit).ToArray());
 18153        var active = await db.PartnerLoginCodes
 18154            .Where(x => x.PartnerId == partner.Id && x.ConsumedAtUtc == null && x.ExpiresAtUtc > now && x.Attempts < Max
 18155            .ToListAsync(ct);
 156
 157        // Сверяем со всеми активными кодами без раннего выхода: время ответа не зависит от того, какой код подошёл.
 18158        var matched = false;
 18159        if (digits.Length == CodeLength)
 160        {
 60161            foreach (var candidate in active)
 14162                matched |= VerifyHash(candidate.CodeHash, digits);
 163        }
 164
 18165        if (matched)
 166        {
 28167            foreach (var candidate in active) candidate.ConsumedAtUtc = now;
 5168            await CompleteLoginAsync(partner, "email_code", ip, ct);
 5169            return new PartnerLoginResult(PartnerLoginStatus.Success, partner);
 170        }
 171
 56172        foreach (var candidate in active) candidate.Attempts++;
 13173        partner.FailedLoginCount++;
 13174        DateTime? lockedUntil = null;
 13175        if (partner.FailedLoginCount >= MaxFailedAttempts)
 176        {
 1177            lockedUntil = now.Add(LockoutDuration);
 1178            partner.LockoutUntilUtc = lockedUntil;
 1179            partner.FailedLoginCount = 0;
 180            // После блокировки прежние коды не годятся: нужен новый запрос.
 5181            foreach (var candidate in active) candidate.ConsumedAtUtc = now;
 182        }
 183
 13184        await db.SaveChangesAsync(ct);
 13185        await audit.WriteAsync("login_failed", PartnerActorKind.Partner, partner.Email!, partner.Id, false,
 13186            lockedUntil is null ? "Неверный или просроченный код" : "Неверный код, вход заблокирован", ipAddress: ip, ct
 13187        return lockedUntil is null
 13188            ? new PartnerLoginResult(PartnerLoginStatus.InvalidCredentials, null)
 13189            : new PartnerLoginResult(PartnerLoginStatus.Locked, null, lockedUntil);
 21190    }
 191
 192    public async Task<PartnerOpResult<bool>> SendInviteAsync(long partnerId, string ownerId, CancellationToken ct = defa
 193    {
 9194        var partner = await db.Partners.FirstOrDefaultAsync(x => x.Id == partnerId, ct);
 10195        if (partner is null) return PartnerOpResult<bool>.Fail("Партнёр не найден.");
 9196        if (string.IsNullOrEmpty(partner.Email)) return PartnerOpResult<bool>.Fail("У партнёра не указана почта.");
 8197        if (!partner.IsActive) return PartnerOpResult<bool>.Fail("Партнёр отключён: сначала включите его.");
 198
 6199        var hadInvite = await db.PartnerLoginCodes
 6200            .AnyAsync(x => x.PartnerId == partnerId && x.Kind == PartnerLoginCodeKind.Invite, ct);
 201
 202        // IssueCodeAsync гасит прежние неиспользованные приглашения.
 6203        var code = await IssueCodeAsync(partner, PartnerLoginCodeKind.Invite, InviteLifetime, null, ct);
 6204        var link = BuildInviteLink(partner.Email, code);
 6205        var sent = await mailer.SendInviteAsync(partner.Email, partner.Name, code, link, ct);
 206
 6207        await audit.WriteAsync(hadInvite ? "invite_resent" : "invite_sent", PartnerActorKind.Owner, ownerId, partner.Id,
 6208            sent ? $"Приглашение отправлено на {partner.Email}" : $"Письмо на {partner.Email} не отправлено", ct: ct);
 6209        return sent
 6210            ? PartnerOpResult<bool>.Success(true)
 6211            : PartnerOpResult<bool>.Fail("Не удалось отправить письмо. Проверьте настройки SMTP и повторите отправку.");
 9212    }
 213
 214    public async Task<PartnerLoginResult> SignInWithGoogleAsync(string email, string? ip, CancellationToken ct = default
 215    {
 2216        var normalized = (email ?? string.Empty).Trim().ToLowerInvariant();
 2217        var partner = string.IsNullOrEmpty(normalized)
 2218            ? null
 2219            : await db.Partners.FirstOrDefaultAsync(x => x.GoogleEmail == normalized, ct);
 220
 2221        if (partner is null)
 222        {
 1223            await audit.WriteAsync("login_failed", PartnerActorKind.Partner, normalized, null, false,
 1224                "Google-почта не заведена", ipAddress: ip, ct: ct);
 1225            return new PartnerLoginResult(PartnerLoginStatus.InvalidCredentials, null);
 226        }
 227
 1228        if (!partner.IsActive)
 229        {
 0230            await audit.WriteAsync("login_failed", PartnerActorKind.Partner, partner.Login, partner.Id, false,
 0231                "Партнёр отключён", ipAddress: ip, ct: ct);
 0232            return new PartnerLoginResult(PartnerLoginStatus.Disabled, null);
 233        }
 234
 1235        await CompleteLoginAsync(partner, "google", ip, ct);
 1236        return new PartnerLoginResult(PartnerLoginStatus.Success, partner);
 2237    }
 238
 239    public async Task<Partner?> ValidateSessionAsync(long partnerId, string securityStamp, CancellationToken ct = defaul
 240    {
 3241        var partner = await db.Partners.AsNoTracking().FirstOrDefaultAsync(x => x.Id == partnerId, ct);
 3242        return partner is { IsActive: true } && partner.SecurityStamp == securityStamp ? partner : null;
 3243    }
 244
 245    private string BuildInviteLink(string email, string code) =>
 6246        $"{options.Value.PublicUrl.TrimEnd('/')}/login?email={Uri.EscapeDataString(email)}&code={code}";
 247
 248    /// <summary>Создаёт код, гасит прежние неиспользованные коды того же вида и возвращает код в открытом виде (для пис
 249    private async Task<string> IssueCodeAsync(
 250        Partner partner, PartnerLoginCodeKind kind, TimeSpan lifetime, string? ip, CancellationToken ct)
 251    {
 19252        var now = DateTime.UtcNow;
 19253        var open = await db.PartnerLoginCodes
 19254            .Where(x => x.PartnerId == partner.Id && x.Kind == kind && x.ConsumedAtUtc == null)
 19255            .ToListAsync(ct);
 47256        foreach (var old in open) old.ConsumedAtUtc = now;
 257
 19258        var code = RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6");
 19259        db.PartnerLoginCodes.Add(new PartnerLoginCode
 19260        {
 19261            PartnerId = partner.Id,
 19262            CodeHash = HashCode(code),
 19263            Kind = kind,
 19264            CreatedAtUtc = now,
 19265            ExpiresAtUtc = now.Add(lifetime),
 19266            RequestIp = ip is { Length: > 64 } ? ip[..64] : ip
 19267        });
 19268        await db.SaveChangesAsync(ct);
 19269        return code;
 19270    }
 271
 272    private static string HashCode(string code)
 273    {
 19274        var salt = RandomNumberGenerator.GetBytes(16);
 19275        var hash = HMACSHA256.HashData(salt, Encoding.UTF8.GetBytes(code));
 19276        return $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}";
 277    }
 278
 279    private static bool VerifyHash(string stored, string code)
 280    {
 14281        var parts = stored.Split('.');
 14282        if (parts.Length != 2) return false;
 283        try
 284        {
 14285            var salt = Convert.FromBase64String(parts[0]);
 14286            var expected = Convert.FromBase64String(parts[1]);
 14287            var actual = HMACSHA256.HashData(salt, Encoding.UTF8.GetBytes(code));
 14288            return CryptographicOperations.FixedTimeEquals(expected, actual);
 289        }
 0290        catch (FormatException)
 291        {
 0292            return false;
 293        }
 14294    }
 295
 296    private async Task CompleteLoginAsync(Partner partner, string method, string? ip, CancellationToken ct)
 297    {
 6298        partner.FailedLoginCount = 0;
 6299        partner.LockoutUntilUtc = null;
 6300        partner.LastLoginAtUtc = DateTime.UtcNow;
 6301        await db.SaveChangesAsync(ct);
 6302        await audit.WriteAsync("login", PartnerActorKind.Partner, partner.Email ?? partner.Login, partner.Id, true,
 6303            $"Вход: {method}", ipAddress: ip, ct: ct);
 6304    }
 305}
 306
 307public static class PartnerLogin
 308{
 309    public static string Normalize(string? login) => (login ?? string.Empty).Trim().ToLowerInvariant();
 310}