Repozytorium, które zawiera (imho) najprostszą możliwą implementację 2FA z użyciem kodów czasowych (totp) i kodów jednorazowych (jako fallback option w przypadku utraty generowania kodów czasowych).
- Dlaczego
- Technologie wiodące
- Użyte biblioteki
- Start projektu
- Kod Krok po kroku
- Podstawowy scenariusz użycia
- Workflow użytkownika video
- Diagram rejestracji
- Diagram logowania
- Techniczny use case
- Produkcyjne TODO
- Aplikacje TOTP
Projekt powstał jako nauka uwierzytelniania 2-składnikowego (2FA), co obecnie staje się standardem dla aplikacji/serwisów, którym zależy na bezpieczeństwie dostępu do zasobów/danych. Projekt powstał z narzuconymi sobie wytycznymi:
- Implementacja 2FA tak łatwo jak to tylko możliwe.
- Optymalny wygląd (minimum komponentów, ale niech nie kłują w oczy).
- Optymalne bezpieczeństwo (minimum kodu, ale możliwie maksimum bezpieczeństwa).
- Fokus na idei zrozumienia i implementacji.
Ostatnie dotyczy tego, że projekt skupia się na zrozumieniu idei działania 2FA z TOTP. Projekt nie jest gotowy produkcji i nie było to istotą tego projektu.
- npm+node
- (opcjonalnie) dotnet CLI - do restore packages i odpalenia solucji w folderze
server/
(zamiast używania Visual Studio)
bootstrap
,@popperjs/core
- stylowanie i komponenty front-end.jwt-decode
- dekodowanie JWT, żeby wydobyć nazwę użytkownika.qrcode.react
- zamiana ciągu znaków na kod QR.react
,react-dom
- podstawa front-endu.react-router-dom
- ścieżki (login/
,register/
) z widokami w aplikacji.react-toastify
- wyskakujące powiadomienia w rogu aplikacji.zustand
- state management (prostsze od reduxa).vite
,eslint/airbnb
,prettier
- styl pisania aplikacji, taki osobiście preferuję i się sprawdza. Dlaczego Vite? Szybki HMR, dependency resolving itp.
Microsoft.AspNetCore.Authentication.JwtBearer
- implementacja JWT jako sposobu autentykacji.Otp.NET
- generowanie i walidacja kluczów czasowych (TOTP).Swashbuckle.AspNetCore
- dokumentacja API Swaggera.System.IdentityModel.Tokens.Jwt
- tworzenie, serializacja i walidacja JWT.
-
Sklonuj repo do siebie.
-
Przejdź do folderu
front/
i wykonaj:npm install
-
Po zainstalowaniu zależności wystarczy wykonać:
npm run dev
W konsoli powinno pokazać, że vite zbudował aplikację i można ją odwiedzić pod adresem np.:
Local: http://127.0.0.1:3000/
-
W drugim oknie konsoli przejdź do folderu
server/
i wykonaj:dotnet restore
lub za pomocą Visual Studio (wystarczy zbudować projekt).
-
Uruchomić lokalny serwer za pomocą komendy:
dotnet run
lub za pomocą Visual Studio (Uruchomić projekt za pomocą
LocalDebug
). -
Adres serwera w pliku
front/src/appConfig.ts
powinien pokrywać się z tym w plikuserver/Properties/launchSettings.json
w sekcjiprofiles.LocalDebug.applicationUrl
z początkiemhttp://
. -
Jeśli wszystko przeszło bez błędów - otworzyć przeglądarkę i wejść do aplikacji (
http://localhost:3000
)
Żeby było trochę łatwiej (zamiast rzucania ogólnymi pojęciami) krok po kroku będzie pokazane co się dzieje w kodzie podczas rejestracji i logowania
-
React wysyła fetch:
response = await fetch(`${appConfig.apiUrl}/user/register`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ username: userName, password, }), });
-
Zapytanie trafia do
UserController.cs
:[AllowAnonymous] [HttpPost] [Route("register")] public ContentResult Register(AuthData data) { // 1. Sprawdza czy użytkownik istnieje w bazie var userDb = JsonDbService.GetUser(data.Username); if (userDb != null && userDb.Username == data.Username) throw new Exception("Użytkownk już istnieje w bazie danych"); // 2. Generuje kody jednorazowe var oneTimeCodes = Guid.NewGuid().GetOneTimeCodes(); // 3. Tworzy nowego użytkownika // Hashuje hasło // Hashuje kody jednorazowe // Tworzy totp-secret var user = new User { Username = data.Username, Password = new PasswordHashService(data.Password).ToArray(), TotpSecret = CryptoService.GetTotpSecret(), OneTimeCodes = oneTimeCodes .Select(x => new PasswordHashService(x).ToArray()) .ToArray() }; // 4. Dodaje użytkownika do bazy JsonDbService.AddUser(user); // 5. Tworzy totp-uri z którego będie wygenerowany kod QR var result = new { totp = new OtpUri(OtpType.Totp, user.TotpSecret, user.Username, "2fa-demo-app").ToString(), oneTimeCodes }; // 6. Zwraca totp-uri i kody jednorazowe return new ContentResult { StatusCode = 200, Content = JsonSerializer.Serialize(result) }; }
-
React wyświetla zwrócone kody jednorazowe w html i generuje QR za pomocą biblioteki
qrcode.react
:<QRCodeSVG style={{ margin: '15px' }} size={300} includeMargin value={totp} />
Gdzie
totp
to totp-uri zwrócone z serwera
-
React wysyła fetch:
response = await fetch(`${appConfig.apiUrl}/user/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ username: userName, password, }), });
-
Zapytanie trafia fo
UserController.cs
:[AllowAnonymous] [HttpPost] [Route("login")] public string? Login(AuthData data) { // 1. Sprawdza, czy użytkownik jest w bazie var user = JsonDbService.GetUser(data.Username); if (user == null) throw new Exception("Nie ma takiego użytkownika w bazie"); // 2. Sprawdza, czy zahashowane hasło w bazie jest poprawne z tym w request if (!user.ValidatePassword(data.Password)) throw new Exception("Nieprawidłowe hasło"); // 3. Generuje krótki JWT dla danego użytkownika, który powinien być odesłany z 2 składnikiem var shortJwt = JwtService.GenerateForUser(user, _appSettings, twoFaToken:true); return shortJwt; }
-
React wyświetla pole do wpisania kodu jednorazowego i po wpisaniu wysyła żądanie:
response = await fetch(`${appConfig.apiUrl}/user/2fa`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `bearer ${shortToken}`, }, body: JSON.stringify(pin), });
Gdzie
shortToken
to token zwrócony przed chwilą z serwera, apin
to kod jednorazowy lub kod totp z aplikacji mobilnej. -
Zapytanie trafia do
UserController.cs
:[AllowAnonymous] [HttpPost] [Route("2fa")] public string? Login2Fa([FromBody]string pinCode) { // 1. Wyciąga token 2fa z requestu var token = JwtService.GetTokenFromRequest(Request); if (token == null) throw new Exception("Nie ma jwt w zapytaniu"); // 2. Pobiera usera z tokenu (jeśli token nie jest typem 2fa, to zwróci null) var user = JwtService.GetUserFromToken(token, _appSettings, true); if (user == null) throw new Exception("Błędny token - brak użytkownia lub token nie jest 2fa"); // 3. Szuka użytkownika w bazie user = JsonDbService.GetUser(user.Username); if (user == null) throw new Exception("Brak użytkownika w bazie"); // 4. Tworzy instancję Totp, żeby serwer wygenerował 6-cyfrowy pin var totp = new Totp(Base32Encoding.ToBytes(user.TotpSecret)); // 5. Serwer sprawdza, czy podany pin zgadza się z wygenerowanym po stronie serwera pinem na bazie TotpSecret var isValid = totp.VerifyTotp(pinCode, out _, VerificationWindow.RfcSpecifiedNetworkDelay); // 6. Jeśli nie, to sprawdza, czy pin jest kodem jednorazowym if (!isValid) { var usedKey = user.OneTimeCodes .FirstOrDefault(x => { PasswordHashService hash = new PasswordHashService(x); return hash.Verify(pinCode); }); if (usedKey == null) return null; // 7. Jeśli kod jednorazowy został wykorzystany, usuwamy z bazy user.OneTimeCodes = user.OneTimeCodes.Except(new [] { usedKey }).ToArray(); } // 8. Generuje właściwy JWT do pozostałych obszarów systemu var jwt = JwtService.GenerateForUser(user, _appSettings); return jwt; }
-
React dostaje JWT i zapisuje go po swojej stronie do autentykacji pozostałych zapytań:
response = await fetch(`${appConfig.apiUrl}/WeatherForecast`, { method: 'GET', headers: { Authorization: `bearer ${token}`, }, });
Gdzie
token
to właściwy JWT z dłuższym okresem ważności.
- Użytkownik otwiera aplikację front-end:
localhost:3000
. - W navbarze po lewej widzi, że jest anonimowym użytkownikiem
- Po wejściu w zakładkę
Pogoda
dostaje informację, że nie ma dostępu, bo jest niezalogowany. - Przechodzi do zakładki
Zarejestruj
. - Wpisuje nazwę użytkownika i hasło.
- Jeśli użytkownik z nazwą jest w bazie, to dostaje odpowiedni komunikat.
- Jeśli użytkownik jest nowy - formularz rejestracyjny znika i pojawia się lista rozwijana z 2 elementami:
Kod QR
iKody jednorazowe
. - Rozwija sekcję
Kod QR
i skanuje kod QR aplikacją do kluczy TOTP (Google Authenticator, Authenticator od Microsoft, Authy od Twilio, Secure SignIn od Synology). - Aplikacja konsumuje QR poprawnie i wyświetla ciąg 6 cyfr co 30 sekund.
- Użytkownik rozwija sekcję
Kody jednorazowe
, zapoznaje się z informacją i kopiuje kody jednorazowe. - Użytkownik przechodzi do zakładki
Zaloguj
. - Wpisuje nazwę użytkownika i hasło podane podczas rejestracji.
- Formularz wyświetla klucz do wpisania.
- Uzytkownik wpisuje 6-cyfrowy kod z aplikacji, lub podaje jeden z kodów jednorazowych.
- Użytkownik jest pomyślnie zalogowany.
- Przechodzi do zakładki
Pogoda
i klikaPobierz pogodę
. - Apliakcja wyświetla karty z informacjami pobranymi z serwera, które są zarezerwowane dla zalogowanych użytkowników.s
- Użytkownik klika
Wyloguj
. - Strona odświeża się, użytkownik jest wylogowany.
demo-2fa.mp4
username, hasło
- serwer dostajeusername
ihasło
.generate(kody), generate(totp-secret)
- serwer generuje jednorazowe kody i klucz do TOTP.username, hash(hasło), hash(kody), totp-secret
- serwer zapisuje użytkownika (z zahashowanym hasłem i jednorazowymi kodami) do bazy.totp-uri + jednorazowe kody
- serwer zwraca użytkownikowi niezahashowane kody jednorazowe i totp-uri (który zawiera klucz TOTP).Skanowanie QR Code z totpUri
- użytkownik skanuje kod QR (który zawiera totp-uri). Klucz TOTP zapisuje się na urządzeniu mobilnym i generuje kody co 30 sekund.
username, hasło
- serwer dostajeusername
ihasło
. Sprawdza, czy hasło zgadza się z tym zaszyfrowanym w bazie.JWT 2fa-token
- serwer generuje token JWT, który służy tylko do 2 składnika uwierzytelniania i ma ważność np. 3 minuty.6 cyfrowy pin
- użytkownik podaje 6-cyfrowy pin z aplikacji mobilnej.2fa-token + pin
- serwer sprawdza, czy JWT token jest prawidłowy. Sprawdza pin pod kątem TOTP, a jeśli to nie zadziała - sprawdza czy jest to kod jednorazowy.JWT session-token
- jeśli token i pin się zgadzają - serwer zwraca JWT (z okresem ważności np. 2 dni) do pozostałych obszarów systemu.
- Klient wysyła żądanie GET o pogodę:
/WeatherForecast
- serwer zwraca 401. - Klient wysyła żądanie POST rejestrację:
/user/register
. - Serwer sprawdza, czy taki użytkownik jest w bazie.
- Jeśli nie to wykonuje:
- Hashuje hasło użytkownika
- Tworzy jednorazowe kody na podstawie generowanego GUID
- Hashuje kody jednorazowe
- Zapisuje użytkownika do bazy (z hashowanym hasłem i kodami)
- Zwraca niezahashowane kody i specjalny TOTP uri do klienta.
- Klient wyświetla kody jednorazowe i generuje kod QR z pobranego TOTP uri.
- Klient wysyła żądanie POST
/user/login
z parametrami:{ username: string, password: string }
- Jeśli hasło jest poprawne - serwer zwraca JWT z krótkim okresem ważności (3 min.). Tylko tym typem tokenu można dostać się do 2 składnika logowania.
- Klient wysyła żądanie POST (ze specjalnym krótkookresowym tokenem)
user/2fa
z parametrami:Gdzie{ pinCode: string }
pinCode
to wygenerowany TOTP lub kod jednorazowy. - Serwer weryfikuje
pinCode
:- Czy jest zgodny z TOTP.
- Jeśli nie, sprawdza czy występuje on w kodach jednorazowych użytkownika.
- Jeśli tak, usuwa kod przypisany do użytkownikowa.
- Jeśli TOTP lub kod jednorazowy są poprawne - zwraca jwt z dłuższym okresem ważności (2 dni), który pozwala autoryzować się do docelowych endpointów.
Sam projekt nie jest kompletnym rozwiązaniem produkcyjnym, a bardziej nauką/pomocą/narzędziem podczas implementacji własnych rozwiązań. Jeśli chcesz użyć tego projektu jako podstawy do stworzenia pełnoprawnej aplikacji, użyj poniższej listy TODO, żeby zaimplementować niezbędne składniki.
-
Utworzenie warstwy serwisowej
Na ten moment logika działania aplikacji jest w Controllerach, żeby zminimalizować kod. W aplikacji produkcyjnej należy oddzielić logikę biznesową aplikacji od controllerów.
-
Sekrety
Projekt posiada wgrane
server/appsettings.json
iserver/appsettings.Development.json
, żeby uprościć uruchomienie projektu. Domyślnie sekretne klucze i wrażliwe informacje nie powinny być wgrywane do repozytorium (należy jest wpisać do pliku.gitignore
) i powinny być przechowywane w bezpiecznym miejscu (zmienne środowiskowe lub wirtualne sejfy). -
Auth explicit
Zawsze podążaj ścieżką:
Zabroń wszystkiego i dokładnie wybierz na co pozwolić
Endpointy po stronie backendu powinny korzystać z atrybutów
[AllowAnonymous]
(w przypadku .NET) jak najrzadziej i być stosowane tylko do metod, co do których masz pewność, że przychodzący request nie musi być uwierzytelniony. -
Logi aplikacji
Należy logować podejrzane zachowania ze strony klienta (niepoprawny/przestarzały kod czasowy, nieporprawny kod jednorazowy, domyślne hasło dla użytkownika niepoprawne, JWT przestarzały itp.). Oraz pozostałe obszary systemu. Należy pamiętać o wielu wartwach logów (DEBUG, INFO, WARNING, ERROR, FATAL), używać odpowiednich narzędzi do składowania logów (txt, sql) i ich przekazywania (np. log4net)
-
Alternatywne 2FA
Aplikacja korzysta z 2 form 2FA - TOTP (kody czasowe) i kody "spalane" (jednorazowe). W produkcyjnej aplikacji warto zatroszczyć się o np:
- Email OTP - jednorazowe hasło wysyłane na skrzynkę mailową z krótkim okresem ważności.
- Key OTP - uwierzytelnianie za pomocą standardu WebAuthn, który zaprasza do współpracy fizyczne klucze/odciski palca/urządzenia BLE, które wygenerują jednorazowy kod (serwer przechowuje klucz publiczny, urządzenie/klucz posiada klucz prywatny, którym tworzy podpis, który dalej jest weryfikowany na serwerze kluczem publicznym przypisanym do użytkownika). Pomocne, jeśli chcemy dać użytkownikowi więcej swobody w wyborze 2 składnika, a czasami wygody (szybciej wykonać 2 składnik odciskiem palca niż przepisywaniem kodu z aplikacji).
- OAuth 2.0 + 2FA - logowanie za pomocą zewnętrznego dostawcy, któremu ufamy (Google, Facebook, Microsoft, Twitter, Discord). Dzięki temu użytkowik nie musi wpisywać kodów przy każdym logowaniu, ale skorzystać z np. odcisku palca. Dostawcy pozwalają wymuszać 2FA jeśli użytkownik chce się zalogować do naszej aplikacji - warto przycisnąć użytkownika, że nie ma wyjścia i musi użyć 2FA (hasło + odcisk palca/potwierdzenie) u zewnętrznego dostawcy.
-
Powiadomienia frontendowe
Końcowy użytkownik aplikacji powinien mieć na tyle dokładne powiadomienia, żeby wiedział co zrobił źle, jakich informacji brakuje i jak to poprawić. Nie może natomiast dostawać powiadomień o błędach systemu ze względów bezpieczeństwa. Komunikaty powinny być krótkie, przejrzyste i dostosowane do tego, żeby pomóc użytkownikowi nawigować po aplikacji
-
Globalny error handler
Obecnie backend zwraca Exceptions, które są trymowane po stronie klienta. W produkcyjnej aplikacji nie można wysyłać błędów systemu do użytkownika, ale posiadać globalny exception handler, który "skonsumuje" błędy i sparsuje odpowiedź zakrywając logikę systemu serwerowego. Należy do tego użyć odpowiednich kodów odpowiedzi HTTP, oraz krótkiej i zrozumiałej wiadomości do użytkownika.
-
HTTPS
Lokalnie serwer przyjmuje i wysyła żądania za pomocą http. W aplikacji produkcyjnej należy zadbać o to, żeby ruch był szyfrowany za pomocą TLS.
-
JWT
Projekt przechowuje aktywny JWT w LocalStorage. Takie rozwiązanie przydaje się w aplikacjach hybrydowych, ale nie zawsze. W zależności od potrzeb składuj go w jedynm z tych miejsc:
- W kodzie JS (store typu redux/zustand, po odświeżeniu strony przepadnie)
- W SessionStorage (tak długo jak okno przeglądarki nie jest zamknięte)
- W LocalStorage (Zostaje nawet po zamknięciu przeglądarki)
- Cookie (z opcją
http_only=true, is_secure
)
Alternatywnie użyj form innych niż JWT, żeby uwierzytelnić użytkownika.
-
Rate limiting/throttling
Żeby ochronić backend i jego endpointy przed atakami DoS oraz dużym zużyciem zasobów przy próbie ataku, warto ustawić ograniczenia na ilość requestów w oknie czasowym (np. max 40 requestów z jednego adresu IP w ciągu minuty). Można skorzystać z gotowych bibliotek lub zaimplementować własne. Czasami ochrona samego backendu nie wystarczy, więc warto dodać tą funkcję do load balancer-ów/proxy, które kierują ruchem (nginx, apache, IIS).
-
CORS
Obecny backend pozwala na requesty z każdego źródła. W aplikacji produkcyjnej należy to ograniczyć tak, żeby backend akceptował zapytania API ze źródeł znanego mu pochodzenia.
-
Skalowanie
Czasami jedna instancja bazy danych, serwera back-end, CDN nie wystarczy przy dużej ilości użytkowników. Należy wtedy zatroszczyć się o wiele replik każdej usługi za pomocą orchestrators (np. kubernetes, docker swarm, docker compose), które pozwalają replikować i zarządzać serwisami na wypadek błędów lub dużego ruchu. W przypadku replikacji relacyjnej bazy danych optymalnym będzie ustawienie replik
1-read-write, n-read
, żeby zapobiec zapisywaniu danych z wielu różnych instancji.
Na rynku można znaleźć aplikacje do skanowania totpUri i generowania czasowych kodów jednorazowych, np.:
- Authenticator od Microsoft
- Authenticator od Google
- Authy od Twilio
- Secure SignIn od Synology
Każda z tych aplikacji działa na tej samej bazie - oferuje generowanie kodów jednorazowych. Obecnie aplikacje korzystają z domyślnych wartości TOTP:
- Okres: 30 sekund (co ile generować nowy kod)
- Algorytm: SHA1
- Cyfry: 6 (ile cyfr generować)
Jeśli chcesz, żeby Twoja aplikacja była zgodna z wszystkimi aplikacjami - użyj powyższych standardów po stronie serwera (backend w repo jest tak właśnie ustawiony).