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.";
}
}