Ogromna większość aplikacji używa uwierzytelnienia opartego o hasło. Jak wszyscy wiemy, użytkownicy mają tendencję to tworzenia haseł słabych i używania ich wielu miejscach. Zobaczmy więc, jak możemy ich „skłonić” do większej dbałości o bezpieczeństwo swojego konta. Posłuży nam do tego walidacja hasła w Spring na różne sposoby, włącznie z porównywaniem z hasłami w wyciekach.
Oczywiście ważnym punktem w tej kwestii jest też 2FA czy zabezpieczenie przed atakami bruteforce – na przykład za pomocą Device Cookie.
Kod zawarty w tym artykule znajdziecie tutaj. Nie zapomnijcie przejrzeć innych branchy, zawierających kod odpowiadający kolejnym sekcjom artykułu.
Bezpieczeństwo a UX
Niektóre z przedstawionych tu technik mogą mieć negatywny wpływ na UX. Stosowałbym je więc z dużą dozą ostrożności wobec użytkowników aplikacji. Mają one jednak o wiele lepsze zastosowanie w bardziej newralgicznych punktach, typu logowanie administratorów lub systemy back office.
Walidacja hasła w Spring
Nasza walidacja hasła w Spring będzie oparta na popularnej bibliotece Passay, więc zacznijmy od zaimportowania jej do pom.xml
:
<dependency> <groupId>org.passay</groupId> <artifactId>passay</artifactId> <version>1.6.1</version> </dependency>
Teraz zdefiniujmy walidacje, których chcemy używać – zbierzemy je w oddzielnym serwisie:
@Component public class PasswordValidatorService { public ValidationResult isValid(String password) { PasswordValidator validator = new PasswordValidator(Arrays.asList( new LengthRule(8, 1024), new CharacterRule(EnglishCharacterData.UpperCase, 1), new CharacterRule(EnglishCharacterData.LowerCase, 1), new CharacterRule(EnglishCharacterData.Digit, 1), new CharacterRule(EnglishCharacterData.Special, 1), new IllegalSequenceRule(EnglishSequenceData.Alphabetical, 3, false), new IllegalSequenceRule(EnglishSequenceData.USQwerty, 3, false), new IllegalSequenceRule(EnglishSequenceData.Numerical, 3, false))); RuleResult ruleResult = validator.validate(new PasswordData(password)); return new ValidationResult(ruleResult.isValid(), validator.getMessages(ruleResult)); } }
@Data public class ValidationResult { private final boolean valid; private final List<String> messages; }
Korzystając z potężnych możliwości biblioteki Passay, jesteśmy w stanie zdefiniować kilka bardzo przydatnych reguł:
- Minimalna długość: 8 znaków.
- Co najmniej jedna mała litera, duża litera, cyfra i znak specjalny.
- Brak sekwencji alfabetycznych, qwerty i numerycznych.
Dzięki tym kilku walidacjom już jesteśmy w stanie bardzo poprawić jakość haseł naszych użytkowników. Zobaczmy więc, jak możemy wpiąć nasze walidacje w Spring Security. Jeśli przechowujemy hasła w bazie danych odpowiednio zabezpieczone, powinny być jedynie dwie sytuacje, w których mamy dostęp do hasła w plaintext: rejestracja i logowanie.
Przy rejestracji
Pierwszym miejscem, którym się zajmiemy, jest rejestracja. Załóżmy, że przeprowadzamy ją przez request na endpoint:
@PostMapping public ResponseEntity<UserData> createUser(@RequestBody CreateUserDto dto) { final UserData user = userService.createUser(dto); return ResponseEntity .status(HttpStatus.CREATED) .body(user); }
Wykorzystamy tutaj mechanizm walidacji przez adnotacje dostarczany przez Spring, na początek więc zaimportujmy odpowiedni starter w pom.xml
:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
Następnie zdefiniujmy adnotację, pod którą podepniemy walidator:
@Constraint(validatedBy = PasswordConstraintValidator.class) @Target({ TYPE, FIELD, ANNOTATION_TYPE }) @Retention(RUNTIME) public @interface StrongPassword { String message() default "Invalid Password"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
Musimy jeszcze zdefiniować walidator, implementujący interfejs ConstraintValidator<>
:
@RequiredArgsConstructor public class PasswordConstraintValidator implements ConstraintValidator<StrongPassword, String> { private final PasswordValidatorService validatorService; @Override public void initialize(StrongPassword s) { } @Override public boolean isValid(String password, ConstraintValidatorContext context) { ValidationResult validationResult = validatorService.isValid(password); if (validationResult.isValid()) { return true; } context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate( String.join(";", validationResult.getMessages())) .addConstraintViolation(); return false; } }
Teraz możemy już za pomocą adnotacji @StrongPassword
oznaczyć pole w naszym DTO:
public class CreateUserDto { private String username; @StrongPassword private String password; }
Ostatnia rzecz, jakiej potrzebujemy do działania to adnotacja @Valid
przed argumentem oznaczonym @RequestBody
:
@PostMapping public ResponseEntity<UserData> createUser(@RequestBody @Valid CreateUserDto dto) { final UserData user = userService.createUser(dto); return ResponseEntity .status(HttpStatus.CREATED) .body(user); }
Teraz, gdy spróbujemy zarejestrować usera ze słabym hasłem:
POST /users { "username": "user", "password": "qwerty" }
Dostaniemy taki rozbudowany komunikat:
400 Bad Request { "errors": { "password": "Password must be 8 or more characters in length.;Password must contain 1 or more uppercase characters.;Password must contain 1 or more digit characters.;Password must contain 1 or more special characters.;Password contains the illegal QWERTY sequence 'qwerty'." } }
Systemy legacy – przy logowaniu
Walidacja hasła w legacy aplikacjach Spring jest już trudniejsza, ponieważ w naszym systemie już istnieją użytkownicy ze słabymi hasłami. Dlatego ich hasła możemy zwalidować jedynie po tym, jak zalogują się do systemu. Aby po pomyślnym uwierzytelnieniu użytkownika sprawdzić jego hasło, zaimplementujmy własny AuthenticationProvider
. Wykorzystamy domyślnie używany przez Springa DaoAuthenticationProvider
, dokładając swoje „3 grosze” w postaci dodatkowego sprawdzenia siły hasła:
@RequiredArgsConstructor public class PasswordStrengthCheckingAuthenticationProvider extends DaoAuthenticationProvider { private final PasswordValidatorService validatorService; @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { super.additionalAuthenticationChecks(userDetails, authentication); ValidationResult validationResult = validatorService.isValid(authentication.getCredentials().toString()); if (!validationResult.isValid()) { throw new PasswordTooWeakException(((User) userDetails).getId()); } } }
Używamy tutaj customowego wyjątku, który dziedziczy po AuthenticationException
:
@Getter public class PasswordTooWeakException extends AuthenticationException { private final UUID userId; public PasswordTooWeakException(UUID userId, Throwable cause) { super("Password too weak", cause); this.userId = userId; } public PasswordTooWeakException(UUID userId) { super("Password too weak"); this.userId = userId; } }
Celowo przemycamy tutaj userId
, przyda się później. Wpinamy nasz nowy AuthenticationProvider
w konfigurację Spring Security:
@Configuration @RequiredArgsConstructor @EnableWebSecurity public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { private final PasswordEncoder passwordEncoder; private final UserService userService; private final PasswordValidatorService passwordValidatorService; @Override protected void configure(AuthenticationManagerBuilder auth) { PasswordStrengthCheckingAuthenticationProvider authenticationProvider = new PasswordStrengthCheckingAuthenticationProvider(passwordValidatorService); authenticationProvider.setPasswordEncoder(passwordEncoder); authenticationProvider.setUserDetailsService(userService); auth.authenticationProvider(authenticationProvider); } ... }
Na koniec musimy obsłużyć nasz customowy wyjątek, przekierowując użytkownika na stronę zmiany hasła z jednorazowym tokenem, który mu na to pozwoli. Zrobimy to implementując AuthenticationFailureHandler
.
@RequiredArgsConstructor class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler { private final OneTimeTokenProvider oneTimeTokenProvider; @Override public void onAuthenticationFailure(HttpServletRequest req, HttpServletResponse resp, AuthenticationException ex) throws IOException { if (ex instanceof PasswordTooWeakException) { final String token = oneTimeTokenProvider.generate(((PasswordTooWeakException) ex).getUserId()); resp.sendRedirect("/change-password?token=" + token); } else { resp.sendRedirect("/login?error"); } } }
OneTimeTokenProvider
celowo pomijam bo niewiele wnosi, jednak prosta implementacja jest dostępna w repo. Wpinamy teraz nasz handler w konfigurację:
@Configuration @RequiredArgsConstructor @EnableWebSecurity public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { private final OneTimeTokenProvider oneTimeTokenProvider; @Override protected void configure(HttpSecurity http) throws Exception { http .formLogin() .failureHandler(new CustomAuthenticationFailureHandler(oneTimeTokenProvider)) .and() ... } }
Stwórzmy jeszcze endpoint do zmiany hasła:
@Controller @RequestMapping("change-password") @RequiredArgsConstructor public class ChangePasswordEndpoint { private final UserService userService; @GetMapping public String changePasswordView() { return "change-password"; } @PostMapping @ResponseStatus(OK) public void changePassword(@RequestBody ChangePasswordDto dto) { userService.changePassword(dto); } }
@Data @NoArgsConstructor @AllArgsConstructor public class ChangePasswordDto { private String token; @StrongPassword private String password; }
Jak widać, tutaj też uzyliśmy adnotacji @StrongPassword
, sprawdzając siłę hasła. Logikę naszej akcji dodajmy do UserService
:
@Service @RequiredArgsConstructor public class UserService implements UserDetailsService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final OneTimeTokenProvider ottProvider; public void changePassword(ChangePasswordDto dto) { final User user = ottProvider.use(dto.getToken()) .flatMap(userRepository::findById) .orElseThrow(PasswordChangeTokenInvalidException::new); user.setPassword(passwordEncoder.encode(dto.getPassword())); userRepository.save(user); } }
OneTimeTokenProvider::use
sprawdza poprawność tokenu, zużywa go i zwraca userId
, dla którego został on wygenerowany. Teraz pozostaje nam już tylko przetestować nowe rozwiązanie – logujemy się jako user ze słabym hasłem:
POST /login username=user0&password=weakPassword
W odpowiedzi taki user dostanie:
302 Found Location: http://localhost:8080/change-password?token=OrYyxec1oWK5GOB4IAg2N6WV
Na stronie, na którą został przekierowany znajdzie formularz zmiany hasła, do którego zostanie zaciągnięty token z URL-a. Użytkownik wpisze nowe hasło i przeglądarka wykona request:
POST /change-password { "token": "OrYyxec1oWK5GOB4IAg2N6WV", "password": "DYSbdG8de$kbhy0" }
Na to zapytanie powinien dostać odpowiedź potwierdzającą zmianę hasła:
200 OK
Sprawdzanie w wyciekach
Bardzo popularną formą ataku na uwierzytelnienie użytkownika jest credential stuffing, czyli iterowanie po listach haseł często pojawiających się w wyciekach. Jeśli mamy użytkowników mających duży impakt na aplikację, warto byłoby ich przed tym zabezpieczyć, sprawdzając, czy ich hasła nie pojawiają się na takich listach.
Walidacja hasła – lista wewnątrz aplikacji Spring
Wiele list jest dostępnych publicznie, na przykład na tym githubie. Dobrym pomysłem na szybką walidację jest wrzucenie do aplikacji takiej listy haseł i dodanie do walidacji, czy hasło już na niej nie figuruje. Aby to osiągnąć, zdefiniujmy nowy serwis:
@Component @RequiredArgsConstructor class InternalFrequentPasswordListAdapter implements FrequentPasswordListProvider { private final PasswordValidatorService validatorService; private final FrequentPasswordRepository repository; Boolean addPassword(String password) { final ValidationResult validationResult = validatorService.isValid(password); if (validationResult.isValid() && !repository.existsById(password)) { repository.save(FrequentPassword.of(password)); return true; } else { return false; } } @Override public ValidationResult validate(String password) { final boolean exists = repository.existsById(password); final List<String> messages = new ArrayList<>(); if (exists) { messages.add("Password is a common password found in data breaches!"); } return new ValidationResult(!exists, messages); } }
Dzięki zastosowaniu tutaj naszego serwisu walidującego, nie zapisujemy haseł, które i tak nie spełniłyby innych naszych wymogów. Dzięki temu nie musimy się wahać przed wrzucaniem naprawdę dużych list haseł do naszej aplikacji. Na przykład, przy regułach opisanych wcześniej, z listy 10-million-password-list-top-1000000.txt zostało mi jedynie około 1250 haseł.
Z innej strony, dodane hasła mogą generować pomysły na dodatkowe walidacje. Na przykład zdarzają się: „1qaz@WSX”, „ZAQ!2wsx”, „1qaz!QAZ” – widać patterny qwerty, ale pionowo 🙂 Może więc to fajny pomysł na nową walidację?
Walidacja hasła – integracja Spring z zewnętrznymi serwisami
Aby na bieżąco kontrolować, czy hasła nie pojawiają się w wyciekach, warto zainteresować się zewnętrznymi serwisami, takimi jak haveibeenpwned.com. Wspomniany serwis udostępnia API, w którym wysyłamy jedynie 5 pierwszych znaków skrótu SHA1 hasła, a w odpowiedzi dostajemy ok. 500 różnych hashy zaczynających się od tej sekwencji, wraz z ilością ich pojawień w wyciekach.
0018A45C4D1DEF81644B54AB7F969B88D65:1 00D4F6E8FA6EECAD2A3AA415EEC418D38EC:2 011053FD0102E94D6AE2F8B83D76FAF94F6:1 012A7CA357541F0AC487871FEEC1891C49C:2 0136E006E24E7D152139815FB0FC6A50B15:2 ...
Jeżeli hasło przechowujemy poprawnie (zahashowane z użyciem soli) to nadal jedynymi miejscami, gdzie możemy odpytać taki serwis jest login i rejestracja.
Wskazówki na stronie rejestracji
Aby dalej zwiększać jakość haseł naszych użytkowników, należy ich edukować. W takim przypadku świetnie sprawdzą się małe wskazówki, które wyświetlimy na stronie rejestracji. Mogą to być na przykład:
- Ustaw długie hasło mające znaczenie, które łatwo zapamiętać, po czym pozamieniaj niektóre znaki na cyfry, duże litery i znaki specjalne, np. „As if I care Paul” -> „4s-1f_i-CaR3,P4uL”.
- Unikaj haseł, które mogą być odgadnięte przez osoby które Cię znają.
- Używaj menedżera haseł.
Podsumowanie
W artykule zbadaliśmy, jak możemy polepszyć bezpieczeństwo haseł w naszej aplikacji Spring. Należy pamiętać, że jest to tylko uzupełnienie innych dobrych praktyk – jak na przykład bezpieczne przechowywanie haseł, nie dopuszczanie do ich logowania. Warto również zachęcać użytkowników do używania uwierzytelnienia dwuskładnikowego.