using Microsoft.Extensions.Logging; using SameBeat.Application.DTO; using SameBeat.Application.Interfejsy; using SameBeat.Application.Komendy; using SameBeat.Application.Walidacja; namespace SameBeat.Application.Serwisy; /// /// Logika logowania hosta/moderatora. Kazde odrzucenie (zly nick, zle haslo, zbanowany, zablokowany) /// wraca jako czytelny -- nigdy jako wyjatek, bo to normalne przypadki /// biznesowe. /// /// ETAP 7 -- blokada konta po zbyt wielu nieudanych probach: to WARSTWA NIEZALEZNA od rate limitingu /// per-IP na /api/auth/login (Program.cs) -- ten tutaj chroni PER KONTO (ten sam nick), niezaleznie /// z jakiego adresu IP przychodza kolejne proby, wiec atak rozproszony na wiele IP tez zostanie /// zatrzymany. Licznik i czas blokady zyja na encji Uzytkownik (NieudaneProbyLogowania/ZablokowanyDoUtc). /// public sealed class SerwisLogowania : ISerwisLogowania { private readonly IRepozytoriumUzytkownikow _repozytoriumUzytkownikow; private readonly IUslugaHaszowaniaHasel _uslugaHaszowaniaHasel; private readonly IGeneratorTokenowJwt _generatorTokenowJwt; private readonly IJednostkaPracy _jednostkaPracy; private readonly int _maksymalnaLiczbaProb; private readonly TimeSpan _czasBlokady; private readonly ILogger _logger; public SerwisLogowania( IRepozytoriumUzytkownikow repozytoriumUzytkownikow, IUslugaHaszowaniaHasel uslugaHaszowaniaHasel, IGeneratorTokenowJwt generatorTokenowJwt, IJednostkaPracy jednostkaPracy, int maksymalnaLiczbaProb, TimeSpan czasBlokady, ILogger logger) { _repozytoriumUzytkownikow = repozytoriumUzytkownikow; _uslugaHaszowaniaHasel = uslugaHaszowaniaHasel; _generatorTokenowJwt = generatorTokenowJwt; _jednostkaPracy = jednostkaPracy; _maksymalnaLiczbaProb = maksymalnaLiczbaProb; _czasBlokady = czasBlokady; _logger = logger; } public async Task> ZalogujAsync(ZalogujKomenda komenda, CancellationToken token = default) { var bladWalidacji = WalidatorZalogujKomenda.Waliduj(komenda); if (bladWalidacji is not null) { return WynikOperacji.Niepowodzenie(bladWalidacji); } var uzytkownik = await _repozytoriumUzytkownikow.ZnajdzPoNickuAsync(komenda.Nick, token); // Celowo ten sam komunikat dla "nie ma nicku" i "zle haslo" -- nie ujawniamy, ktory z nickow // istnieje w systemie. const string bladNieprawidloweDane = "Nieprawidlowy nick lub haslo."; if (uzytkownik is null || uzytkownik.HasloHash is null) { _logger.LogInformation("Nieudana proba logowania -- nick {Nick} nie istnieje lub nie ma ustawionego hasla.", komenda.Nick); return WynikOperacji.Niepowodzenie(bladNieprawidloweDane); } if (uzytkownik.Zbanowany) { _logger.LogInformation("Proba logowania zbanowanego uzytkownika {UzytkownikId}.", uzytkownik.Id); return WynikOperacji.Niepowodzenie("Konto jest zablokowane."); } var teraz = DateTime.UtcNow; if (uzytkownik.CzyZablokowany(teraz)) { _logger.LogInformation("Proba logowania zablokowanego (brute force) uzytkownika {UzytkownikId}.", uzytkownik.Id); return WynikOperacji.Niepowodzenie(ZbudujKomunikatBlokady(uzytkownik.ZablokowanyDoUtc!.Value, teraz)); } var wynikWeryfikacji = _uslugaHaszowaniaHasel.Weryfikuj(uzytkownik, uzytkownik.HasloHash, komenda.Haslo); if (wynikWeryfikacji == WynikWeryfikacjiHasla.Niepoprawne) { uzytkownik.ZanotujNieudaneLogowanie(_maksymalnaLiczbaProb, _czasBlokady); await _jednostkaPracy.ZapiszZmianyAsync(token); if (uzytkownik.CzyZablokowany(teraz)) { // Ta konkretna proba wlasnie przekroczyla limit -- host ma od razu widziec komunikat // blokady, nie kolejny generyczny "zle haslo", zeby wiedzial co sie stalo i na jak dlugo. _logger.LogWarning("Konto uzytkownika {UzytkownikId} zablokowane po {LiczbaProb} nieudanych probach logowania, do {ZablokowanyDoUtc:o}.", uzytkownik.Id, uzytkownik.NieudaneProbyLogowania, uzytkownik.ZablokowanyDoUtc); return WynikOperacji.Niepowodzenie(ZbudujKomunikatBlokady(uzytkownik.ZablokowanyDoUtc!.Value, teraz)); } _logger.LogInformation("Nieudana proba logowania -- bledne haslo dla uzytkownika {UzytkownikId} (proba {LiczbaProb}/{Maksimum}).", uzytkownik.Id, uzytkownik.NieudaneProbyLogowania, _maksymalnaLiczbaProb); return WynikOperacji.Niepowodzenie(bladNieprawidloweDane); } // Udane logowanie (rowniez to wymagajace rehaszowania ponizej) -- zerujemy licznik nieudanych // prob, zeby stara passa niepowodzen nie liczyla sie na poczet przyszlej blokady. uzytkownik.ZanotujUdaneLogowanie(); if (wynikWeryfikacji == WynikWeryfikacjiHasla.PoprawneWymagaRehaszowania) { // Algorytm hashowania zostal zaktualizowany od czasu ustawienia hasla -- przeliczamy // hash "przy okazji" udanego logowania, zeby nie wymuszac zmiany hasla na uzytkowniku. uzytkownik.UstawHasloHash(_uslugaHaszowaniaHasel.Haszuj(uzytkownik, komenda.Haslo)); } await _jednostkaPracy.ZapiszZmianyAsync(token); var (jwt, wygasaUtc) = _generatorTokenowJwt.Wygeneruj(uzytkownik); _logger.LogInformation("Uzytkownik {UzytkownikId} zalogowany pomyslnie.", uzytkownik.Id); return WynikOperacji.Ok( new OdpowiedzLogowaniaDto(jwt, wygasaUtc, uzytkownik.Id, uzytkownik.Nick, uzytkownik.Rola)); } /// Zaokraglamy w gore do pelnej minuty -- "sprobuj za 0 minut" wygladaloby na blad, nie ochrone. private static string ZbudujKomunikatBlokady(DateTime zablokowanyDoUtc, DateTime terazUtc) { var pozostaleMinuty = Math.Max(1, (int)Math.Ceiling((zablokowanyDoUtc - terazUtc).TotalMinutes)); return $"Zbyt wiele nieudanych prob logowania. Sprobuj ponownie za {pozostaleMinuty} min."; } }