Strona główna » Walidacja haseł w Spring (3 proste strategie)

Walidacja haseł w Spring (3 proste strategie)

by Grzegorz Sowa

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.

You may also like

Leave a Comment