Разрешить проверку подлинности только в принятых местах с помощью Spring Security

Разрешить проверку подлинности только в принятых местах с помощью Spring Security

1. обзор

В этом руководстве мы сосредоточимся на очень интересной функции безопасности - защите учетной записи пользователя в зависимости от его местоположения.

Проще говоря,we’ll block any login from unusual or non-standard locations и позволяет пользователю безопасно активировать новые местоположения.

Этоpart of the registration series и, естественно, он основан на существующей кодовой базе.

2. Модель местоположения пользователя

Во-первых, давайте взглянем на нашу модельUserLocation, которая содержит информацию о местах входа пользователей; у каждого пользователя есть хотя бы одно местоположение, связанное с его учетной записью:

@Entity
public class UserLocation {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String country;

    private boolean enabled;

    @ManyToOne(targetEntity = User.class, fetch = FetchType.EAGER)
    @JoinColumn(nullable = false, name = "user_id")
    private User user;

    public UserLocation() {
        super();
        enabled = false;
    }

    public UserLocation(String country, User user) {
        super();
        this.country = country;
        this.user = user;
        enabled = false;
    }
    ...
}

И мы собираемся добавить простую операцию получения в наш репозиторий:

public interface UserLocationRepository extends JpaRepository {
    UserLocation findByCountryAndUser(String country, User user);
}

Обратите внимание, что

  • НовыйUserLocation по умолчанию отключен

  • У каждого пользователя есть по крайней мере одно местоположение, связанное с его учетными записями, которое является первым местоположением, к которому они обращались к приложению при регистрации.

3. Регистрация

Теперь давайте обсудим, как изменить процесс регистрации, чтобы добавить местоположение пользователя по умолчанию:

@RequestMapping(value = "/user/registration", method = RequestMethod.POST)
@ResponseBody
public GenericResponse registerUserAccount(@Valid UserDto accountDto,
  HttpServletRequest request) {

    User registered = userService.registerNewUserAccount(accountDto);
    userService.addUserLocation(registered, getClientIP(request));
    ...
}

При реализации сервиса мы получим страну по IP-адресу пользователя:

public void addUserLocation(User user, String ip) {
    InetAddress ipAddress = InetAddress.getByName(ip);
    String country
      = databaseReader.country(ipAddress).getCountry().getName();
    UserLocation loc = new UserLocation(country, user);
    loc.setEnabled(true);
    loc = userLocationRepo.save(loc);
}

Обратите внимание, что мы используем базу данныхGeoLite2, чтобы получить страну по IP-адресу. Чтобы использоватьGeoLite2, нам нужна зависимость maven:


    com.maxmind.geoip2
    geoip2
    2.9.0

И нам также нужно определить простой бин:

@Bean
public DatabaseReader databaseReader() throws IOException, GeoIp2Exception {
    File resource = new File("src/main/resources/GeoLite2-Country.mmdb");
    return new DatabaseReader.Builder(resource).build();
}

Мы загрузили базу данныхGeoLite2 Country из MaxMind здесь.

4. Безопасный вход

Теперь, когда у нас есть страна пользователя по умолчанию, мы добавим простую проверку местоположения после аутентификации:

@Autowired
private DifferentLocationChecker differentLocationChecker;

@Bean
public DaoAuthenticationProvider authProvider() {
    CustomAuthenticationProvider authProvider = new CustomAuthenticationProvider();
    authProvider.setUserDetailsService(userDetailsService);
    authProvider.setPasswordEncoder(encoder());
    authProvider.setPostAuthenticationChecks(differentLocationChecker);
    return authProvider;
}

А вот нашDifferentLocationChecker:

@Component
public class DifferentLocationChecker implements UserDetailsChecker {

    @Autowired
    private IUserService userService;

    @Autowired
    private HttpServletRequest request;

    @Autowired
    private ApplicationEventPublisher eventPublisher;

    @Override
    public void check(UserDetails userDetails) {
        String ip = getClientIP();
        NewLocationToken token = userService.isNewLoginLocation(userDetails.getUsername(), ip);
        if (token != null) {
            String appUrl =
              "http://"
              + request.getServerName()
              + ":" + request.getServerPort()
              + request.getContextPath();

            eventPublisher.publishEvent(
              new OnDifferentLocationLoginEvent(
                request.getLocale(), userDetails.getUsername(), ip, token, appUrl));
            throw new UnusualLocationException("unusual location");
        }
    }

    private String getClientIP() {
        String xfHeader = request.getHeader("X-Forwarded-For");
        if (xfHeader == null) {
            return request.getRemoteAddr();
        }
        return xfHeader.split(",")[0];
    }
}

Обратите внимание, что мы использовали setPostAuthenticationChecks(), чтобыthe check only run after successful authentication - когда пользователь предоставил правильные учетные данные.

Кроме того, наш пользовательскийUnusualLocationException - это простойAuthenticationException.

Нам также необходимо изменить нашAuthenticationFailureHandler, чтобы настроить сообщение об ошибке:

@Override
public void onAuthenticationFailure(...) {
    ...
    else if (exception.getMessage().equalsIgnoreCase("unusual location")) {
        errorMessage = messages.getMessage("auth.message.unusual.location", null, locale);
    }
}

Теперь давайте подробно рассмотрим реализациюisNewLoginLocation():

@Override
public NewLocationToken isNewLoginLocation(String username, String ip) {
    try {
        InetAddress ipAddress = InetAddress.getByName(ip);
        String country
          = databaseReader.country(ipAddress).getCountry().getName();

        User user = repository.findByEmail(username);
        UserLocation loc = userLocationRepo.findByCountryAndUser(country, user);
        if ((loc == null) || !loc.isEnabled()) {
            return createNewLocationToken(country, user);
        }
    } catch (Exception e) {
        return null;
    }
    return null;
}

Обратите внимание, как, когда пользователь предоставляет правильные учетные данные, мы затем проверяем их местоположение. Если местоположение уже связано с этой учетной записью пользователя, то пользователь может успешно пройти аутентификацию.

Если нет, мы создаемNewLocationToken и отключенныйUserLocation - чтобы позволить пользователю включить это новое местоположение. Подробнее об этом в следующих разделах.

private NewLocationToken createNewLocationToken(String country, User user) {
    UserLocation loc = new UserLocation(country, user);
    loc = userLocationRepo.save(loc);
    NewLocationToken token = new NewLocationToken(UUID.randomUUID().toString(), loc);
    return newLocationTokenRepository.save(token);
}

Наконец, вот простая реализацияNewLocationToken, позволяющая пользователям связывать новые местоположения со своей учетной записью:

@Entity
public class NewLocationToken {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String token;

    @OneToOne(targetEntity = UserLocation.class, fetch = FetchType.EAGER)
    @JoinColumn(nullable = false, name = "user_location_id")
    private UserLocation userLocation;

    ...
}

5. Событие входа в другое место

Когда пользователь входит в систему из другого места, мы создалиNewLocationToken и использовали его для запускаOnDifferentLocationLoginEvent:

public class OnDifferentLocationLoginEvent extends ApplicationEvent {
    private Locale locale;
    private String username;
    private String ip;
    private NewLocationToken token;
    private String appUrl;
}

DifferentLocationLoginListener обрабатывает наше событие следующим образом:

@Component
public class DifferentLocationLoginListener
  implements ApplicationListener {

    @Autowired
    private MessageSource messages;

    @Autowired
    private JavaMailSender mailSender;

    @Autowired
    private Environment env;

    @Override
    public void onApplicationEvent(OnDifferentLocationLoginEvent event) {
        String enableLocUri = event.getAppUrl() + "/user/enableNewLoc?token="
          + event.getToken().getToken();
        String changePassUri = event.getAppUrl() + "/changePassword.html";
        String recipientAddress = event.getUsername();
        String subject = "Login attempt from different location";
        String message = messages.getMessage("message.differentLocation", new Object[] {
          new Date().toString(),
          event.getToken().getUserLocation().getCountry(),
          event.getIp(), enableLocUri, changePassUri
          }, event.getLocale());

        SimpleMailMessage email = new SimpleMailMessage();
        email.setTo(recipientAddress);
        email.setSubject(subject);
        email.setText(message);
        email.setFrom(env.getProperty("support.email"));
        mailSender.send(email);
    }
}

Обратите внимание, какwhen the user logs in from a different location, we’ll send an email to notify them.

Если кто-то другой попытался войти в свою учетную запись, он, конечно, изменит свой пароль. Если они распознают попытку аутентификации, они смогут связать новое место входа со своей учетной записью.

6. Включить новое место для входа

Наконец, теперь, когда пользователь был уведомлен о подозрительной активности, давайте взглянем наhow the application will handle enabling the new location:

@RequestMapping(value = "/user/enableNewLoc", method = RequestMethod.GET)
public String enableNewLoc(Locale locale, Model model, @RequestParam("token") String token) {
    String loc = userService.isValidNewLocationToken(token);
    if (loc != null) {
        model.addAttribute(
          "message",
          messages.getMessage("message.newLoc.enabled", new Object[] { loc }, locale)
        );
    } else {
        model.addAttribute(
          "message",
          messages.getMessage("message.error", null, locale)
        );
    }
    return "redirect:/login?lang=" + locale.getLanguage();
}

И наш методisValidNewLocationToken():

@Override
public String isValidNewLocationToken(String token) {
    NewLocationToken locToken = newLocationTokenRepository.findByToken(token);
    if (locToken == null) {
        return null;
    }
    UserLocation userLoc = locToken.getUserLocation();
    userLoc.setEnabled(true);
    userLoc = userLocationRepo.save(userLoc);
    newLocationTokenRepository.delete(locToken);
    return userLoc.getCountry();
}

Проще говоря, мы включимUserLocation, связанные с токеном, а затем удалим токен.

7. Заключение

В этом руководстве мы сосредоточились на новом мощном механизме для добавления безопасности в наши приложения -restricting unexpected user activity based on their location.

Как всегда, полную реализацию можно найти вover on GiHub.

Related