< Summary

Information
Class: Services.PartnerServices.PartnerLogin
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/PartnerServices/PartnerAuthService.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 1
Coverable lines: 1
Total lines: 310
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 2
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Normalize(...)0%620%

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
 61public sealed class PartnerAuthService(
 62    ApplicationDbContext db,
 63    IPartnerAuditService audit,
 64    IPartnerLoginMailer mailer,
 65    IOptions<PartnerOfficeOptions> options) : IPartnerAuthService
 66{
 67    internal const int MaxFailedAttempts = 5;
 68    internal const int MaxCodeAttempts = 5;
 69    internal const int CodeLength = 6;
 70    internal static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15);
 71    internal static readonly TimeSpan LoginCodeLifetime = TimeSpan.FromMinutes(10);
 72    internal static readonly TimeSpan InviteLifetime = TimeSpan.FromDays(7);
 73    internal static readonly TimeSpan ResendPause = TimeSpan.FromSeconds(60);
 74
 75    public async Task<string?> LookupNameByEmailAsync(string email, CancellationToken ct = default)
 76    {
 77        var normalized = PartnerEmail.Normalize(email);
 78        if (!PartnerEmail.IsValid(normalized)) return null;
 79
 80        return await db.Partners.AsNoTracking()
 81            .Where(x => x.Email == normalized && x.IsActive)
 82            .Select(x => x.Name)
 83            .FirstOrDefaultAsync(ct);
 84    }
 85
 86    public async Task<PartnerCodeRequestResult> RequestCodeAsync(string email, string? ip, CancellationToken ct = defaul
 87    {
 88        var normalized = PartnerEmail.Normalize(email);
 89        var partner = PartnerEmail.IsValid(normalized)
 90            ? await db.Partners.FirstOrDefaultAsync(x => x.Email == normalized, ct)
 91            : null;
 92
 93        if (partner is null || !partner.IsActive)
 94        {
 95            await audit.WriteAsync("code_request_failed", PartnerActorKind.Partner, normalized, partner?.Id, false,
 96                partner is null ? "Почта не заведена" : "Партнёр отключён", ipAddress: ip, ct: ct);
 97            return new PartnerCodeRequestResult(PartnerCodeRequestStatus.NotSent);
 98        }
 99
 100        var now = DateTime.UtcNow;
 101        if (partner.LockoutUntilUtc is { } until && until > now)
 102            return new PartnerCodeRequestResult(PartnerCodeRequestStatus.Locked, LockedUntilUtc: until);
 103
 104        var lastIssuedAt = await db.PartnerLoginCodes
 105            .Where(x => x.PartnerId == partner.Id && x.Kind == PartnerLoginCodeKind.Login)
 106            .OrderByDescending(x => x.CreatedAtUtc)
 107            .Select(x => (DateTime?)x.CreatedAtUtc)
 108            .FirstOrDefaultAsync(ct);
 109        if (lastIssuedAt is { } last && now - last < ResendPause)
 110        {
 111            var wait = (int)Math.Ceiling((ResendPause - (now - last)).TotalSeconds);
 112            return new PartnerCodeRequestResult(PartnerCodeRequestStatus.Throttled, wait);
 113        }
 114
 115        var code = await IssueCodeAsync(partner, PartnerLoginCodeKind.Login, LoginCodeLifetime, ip, ct);
 116        var sent = await mailer.SendLoginCodeAsync(partner.Email!, partner.Name, code, ct);
 117        await audit.WriteAsync("code_requested", PartnerActorKind.Partner, partner.Email!, partner.Id, sent,
 118            sent ? "Код отправлен на почту" : "Письмо с кодом не отправлено", ipAddress: ip, ct: ct);
 119        return new PartnerCodeRequestResult(sent ? PartnerCodeRequestStatus.Sent : PartnerCodeRequestStatus.MailFailed);
 120    }
 121
 122    public async Task<PartnerLoginResult> SignInWithCodeAsync(
 123        string email, string code, string? ip, CancellationToken ct = default)
 124    {
 125        var normalized = PartnerEmail.Normalize(email);
 126        var partner = PartnerEmail.IsValid(normalized)
 127            ? await db.Partners.FirstOrDefaultAsync(x => x.Email == normalized, ct)
 128            : null;
 129
 130        if (partner is null)
 131        {
 132            await audit.WriteAsync("login_failed", PartnerActorKind.Partner, normalized, null, false,
 133                "Почта не заведена", ipAddress: ip, ct: ct);
 134            return new PartnerLoginResult(PartnerLoginStatus.InvalidCredentials, null);
 135        }
 136
 137        if (!partner.IsActive)
 138        {
 139            await audit.WriteAsync("login_failed", PartnerActorKind.Partner, partner.Email!, partner.Id, false,
 140                "Партнёр отключён", ipAddress: ip, ct: ct);
 141            return new PartnerLoginResult(PartnerLoginStatus.Disabled, null);
 142        }
 143
 144        var now = DateTime.UtcNow;
 145        if (partner.LockoutUntilUtc is { } until && until > now)
 146        {
 147            await audit.WriteAsync("login_failed", PartnerActorKind.Partner, partner.Email!, partner.Id, false,
 148                "Вход заблокирован после неверных кодов", ipAddress: ip, ct: ct);
 149            return new PartnerLoginResult(PartnerLoginStatus.Locked, null, until);
 150        }
 151
 152        var digits = new string((code ?? string.Empty).Where(char.IsDigit).ToArray());
 153        var active = await db.PartnerLoginCodes
 154            .Where(x => x.PartnerId == partner.Id && x.ConsumedAtUtc == null && x.ExpiresAtUtc > now && x.Attempts < Max
 155            .ToListAsync(ct);
 156
 157        // Сверяем со всеми активными кодами без раннего выхода: время ответа не зависит от того, какой код подошёл.
 158        var matched = false;
 159        if (digits.Length == CodeLength)
 160        {
 161            foreach (var candidate in active)
 162                matched |= VerifyHash(candidate.CodeHash, digits);
 163        }
 164
 165        if (matched)
 166        {
 167            foreach (var candidate in active) candidate.ConsumedAtUtc = now;
 168            await CompleteLoginAsync(partner, "email_code", ip, ct);
 169            return new PartnerLoginResult(PartnerLoginStatus.Success, partner);
 170        }
 171
 172        foreach (var candidate in active) candidate.Attempts++;
 173        partner.FailedLoginCount++;
 174        DateTime? lockedUntil = null;
 175        if (partner.FailedLoginCount >= MaxFailedAttempts)
 176        {
 177            lockedUntil = now.Add(LockoutDuration);
 178            partner.LockoutUntilUtc = lockedUntil;
 179            partner.FailedLoginCount = 0;
 180            // После блокировки прежние коды не годятся: нужен новый запрос.
 181            foreach (var candidate in active) candidate.ConsumedAtUtc = now;
 182        }
 183
 184        await db.SaveChangesAsync(ct);
 185        await audit.WriteAsync("login_failed", PartnerActorKind.Partner, partner.Email!, partner.Id, false,
 186            lockedUntil is null ? "Неверный или просроченный код" : "Неверный код, вход заблокирован", ipAddress: ip, ct
 187        return lockedUntil is null
 188            ? new PartnerLoginResult(PartnerLoginStatus.InvalidCredentials, null)
 189            : new PartnerLoginResult(PartnerLoginStatus.Locked, null, lockedUntil);
 190    }
 191
 192    public async Task<PartnerOpResult<bool>> SendInviteAsync(long partnerId, string ownerId, CancellationToken ct = defa
 193    {
 194        var partner = await db.Partners.FirstOrDefaultAsync(x => x.Id == partnerId, ct);
 195        if (partner is null) return PartnerOpResult<bool>.Fail("Партнёр не найден.");
 196        if (string.IsNullOrEmpty(partner.Email)) return PartnerOpResult<bool>.Fail("У партнёра не указана почта.");
 197        if (!partner.IsActive) return PartnerOpResult<bool>.Fail("Партнёр отключён: сначала включите его.");
 198
 199        var hadInvite = await db.PartnerLoginCodes
 200            .AnyAsync(x => x.PartnerId == partnerId && x.Kind == PartnerLoginCodeKind.Invite, ct);
 201
 202        // IssueCodeAsync гасит прежние неиспользованные приглашения.
 203        var code = await IssueCodeAsync(partner, PartnerLoginCodeKind.Invite, InviteLifetime, null, ct);
 204        var link = BuildInviteLink(partner.Email, code);
 205        var sent = await mailer.SendInviteAsync(partner.Email, partner.Name, code, link, ct);
 206
 207        await audit.WriteAsync(hadInvite ? "invite_resent" : "invite_sent", PartnerActorKind.Owner, ownerId, partner.Id,
 208            sent ? $"Приглашение отправлено на {partner.Email}" : $"Письмо на {partner.Email} не отправлено", ct: ct);
 209        return sent
 210            ? PartnerOpResult<bool>.Success(true)
 211            : PartnerOpResult<bool>.Fail("Не удалось отправить письмо. Проверьте настройки SMTP и повторите отправку.");
 212    }
 213
 214    public async Task<PartnerLoginResult> SignInWithGoogleAsync(string email, string? ip, CancellationToken ct = default
 215    {
 216        var normalized = (email ?? string.Empty).Trim().ToLowerInvariant();
 217        var partner = string.IsNullOrEmpty(normalized)
 218            ? null
 219            : await db.Partners.FirstOrDefaultAsync(x => x.GoogleEmail == normalized, ct);
 220
 221        if (partner is null)
 222        {
 223            await audit.WriteAsync("login_failed", PartnerActorKind.Partner, normalized, null, false,
 224                "Google-почта не заведена", ipAddress: ip, ct: ct);
 225            return new PartnerLoginResult(PartnerLoginStatus.InvalidCredentials, null);
 226        }
 227
 228        if (!partner.IsActive)
 229        {
 230            await audit.WriteAsync("login_failed", PartnerActorKind.Partner, partner.Login, partner.Id, false,
 231                "Партнёр отключён", ipAddress: ip, ct: ct);
 232            return new PartnerLoginResult(PartnerLoginStatus.Disabled, null);
 233        }
 234
 235        await CompleteLoginAsync(partner, "google", ip, ct);
 236        return new PartnerLoginResult(PartnerLoginStatus.Success, partner);
 237    }
 238
 239    public async Task<Partner?> ValidateSessionAsync(long partnerId, string securityStamp, CancellationToken ct = defaul
 240    {
 241        var partner = await db.Partners.AsNoTracking().FirstOrDefaultAsync(x => x.Id == partnerId, ct);
 242        return partner is { IsActive: true } && partner.SecurityStamp == securityStamp ? partner : null;
 243    }
 244
 245    private string BuildInviteLink(string email, string code) =>
 246        $"{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    {
 252        var now = DateTime.UtcNow;
 253        var open = await db.PartnerLoginCodes
 254            .Where(x => x.PartnerId == partner.Id && x.Kind == kind && x.ConsumedAtUtc == null)
 255            .ToListAsync(ct);
 256        foreach (var old in open) old.ConsumedAtUtc = now;
 257
 258        var code = RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6");
 259        db.PartnerLoginCodes.Add(new PartnerLoginCode
 260        {
 261            PartnerId = partner.Id,
 262            CodeHash = HashCode(code),
 263            Kind = kind,
 264            CreatedAtUtc = now,
 265            ExpiresAtUtc = now.Add(lifetime),
 266            RequestIp = ip is { Length: > 64 } ? ip[..64] : ip
 267        });
 268        await db.SaveChangesAsync(ct);
 269        return code;
 270    }
 271
 272    private static string HashCode(string code)
 273    {
 274        var salt = RandomNumberGenerator.GetBytes(16);
 275        var hash = HMACSHA256.HashData(salt, Encoding.UTF8.GetBytes(code));
 276        return $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}";
 277    }
 278
 279    private static bool VerifyHash(string stored, string code)
 280    {
 281        var parts = stored.Split('.');
 282        if (parts.Length != 2) return false;
 283        try
 284        {
 285            var salt = Convert.FromBase64String(parts[0]);
 286            var expected = Convert.FromBase64String(parts[1]);
 287            var actual = HMACSHA256.HashData(salt, Encoding.UTF8.GetBytes(code));
 288            return CryptographicOperations.FixedTimeEquals(expected, actual);
 289        }
 290        catch (FormatException)
 291        {
 292            return false;
 293        }
 294    }
 295
 296    private async Task CompleteLoginAsync(Partner partner, string method, string? ip, CancellationToken ct)
 297    {
 298        partner.FailedLoginCount = 0;
 299        partner.LockoutUntilUtc = null;
 300        partner.LastLoginAtUtc = DateTime.UtcNow;
 301        await db.SaveChangesAsync(ct);
 302        await audit.WriteAsync("login", PartnerActorKind.Partner, partner.Email ?? partner.Login, partner.Id, true,
 303            $"Вход: {method}", ipAddress: ip, ct: ct);
 304    }
 305}
 306
 307public static class PartnerLogin
 308{
 0309    public static string Normalize(string? login) => (login ?? string.Empty).Trim().ToLowerInvariant();
 310}

Methods/Properties

Normalize(System.String)