«1. Введение

В этом уроке мы собираемся продемонстрировать, как мы можем проверить, входят ли наши пользователи в систему с нового устройства/местоположения.

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

2. Местоположение пользователей и информация об устройстве

Нам нужны две вещи: местоположение наших пользователей и информация об устройствах, которые они используют для входа в систему.

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

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

2.1. Местоположение устройства

Прежде чем мы сможем оценить местоположение наших пользователей, нам нужно получить их исходный IP-адрес.

Мы можем сделать это, используя:

    X-Forwarded-For — де-факто стандартный заголовок для определения исходного IP-адреса клиента, подключающегося к веб-серверу через HTTP-прокси или балансировщик нагрузки ServletRequest.getRemoteAddr () — служебный метод, который возвращает исходный IP-адрес клиента или последнего прокси-сервера, отправившего запрос

Извлечение IP-адреса пользователя из HTTP-запроса не совсем надежно, поскольку они могут быть подделаны. Однако давайте упростим это в нашем руководстве и предположим, что это не так.

Получив IP-адрес, мы можем преобразовать его в реальное местоположение с помощью геолокации.

2.2. Сведения об устройстве

Подобно исходному IP-адресу, существует также заголовок HTTP, который содержит информацию об устройстве, которое использовалось для отправки запроса, называемого User-Agent.

Короче говоря, он несет информацию, которая позволяет нам идентифицировать тип приложения, операционную систему и поставщика/версию программного обеспечения запрашивающего пользовательского агента.

Вот пример того, как это может выглядеть:

User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 
  (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36

В нашем примере выше устройство работает под управлением Mac OS X 10.14 и использует Chrome 71.0 для отправки запроса.

Вместо того, чтобы реализовывать синтаксический анализатор User-Agent с нуля, мы собираемся прибегнуть к существующим решениям, которые уже протестированы и являются более надежными.

3. Обнаружение нового устройства или местоположения

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

public class MySimpleUrlAuthenticationSuccessHandler 
  implements AuthenticationSuccessHandler {
    //...
    @Override
    public void onAuthenticationSuccess(
      final HttpServletRequest request,
      final HttpServletResponse response,
      final Authentication authentication)
      throws IOException {
        handle(request, response, authentication);
        //...
        loginNotification(authentication, request);
    }

    private void loginNotification(Authentication authentication, 
      HttpServletRequest request) {
        try {
            if (authentication.getPrincipal() instanceof User) { 
                deviceService.verifyDevice(((User)authentication.getPrincipal()), request); 
            }
        } catch(Exception e) {
            logger.error("An error occurred verifying device or location");
            throw new RuntimeException(e);
        }
    }
    //...
}

Мы просто добавили вызов в наш новый компонент: DeviceService. Этот компонент будет инкапсулировать все, что нам нужно для идентификации новых устройств/местоположений и уведомления наших пользователей.

Однако, прежде чем мы перейдем к нашему DeviceService, давайте создадим наш объект DeviceMetadata для сохранения данных наших пользователей с течением времени:

@Entity
public class DeviceMetadata {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private Long userId;
    private String deviceDetails;
    private String location;
    private Date lastLoggedIn;
    //...
}

И его репозиторий:

public interface DeviceMetadataRepository extends JpaRepository<DeviceMetadata, Long> {
    List<DeviceMetadata> findByUserId(Long userId);
}

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

4. Извлечение местоположения нашего пользователя

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

private String extractIp(HttpServletRequest request) {
    String clientIp;
    String clientXForwardedForIp = request
      .getHeader("x-forwarded-for");
    if (nonNull(clientXForwardedForIp)) {
        clientIp = parseXForwardedHeader(clientXForwardedForIp);
    } else {
        clientIp = request.getRemoteAddr();
    }
    return clientIp;
}

Если в запросе есть заголовок X-Forwarded-For, мы воспользуюсь им для извлечения своего IP-адреса; в противном случае мы будем использовать метод getRemoteAddr().

Получив их IP-адреса, мы можем оценить их местоположение с помощью Maxmind:

private String getIpLocation(String ip) {
    String location = UNKNOWN;
    InetAddress ipAddress = InetAddress.getByName(ip);
    CityResponse cityResponse = databaseReader
      .city(ipAddress);
        
    if (Objects.nonNull(cityResponse) &&
      Objects.nonNull(cityResponse.getCity()) &&
      !Strings.isNullOrEmpty(cityResponse.getCity().getName())) {
        location = cityResponse.getCity().getName();
    }    
    return location;
}

5. Информация об устройствах пользователей

Поскольку заголовок User-Agent содержит всю необходимую нам информацию, он вопрос только в том, чтобы его извлечь. Как мы упоминали ранее, с помощью парсера User-Agent (в данном случае uap-java) получить эту информацию становится довольно просто:

private String getDeviceDetails(String userAgent) {
    String deviceDetails = UNKNOWN;
    
    Client client = parser.parse(userAgent);
    if (Objects.nonNull(client)) {
        deviceDetails = client.userAgent.family
          + " " + client.userAgent.major + "." 
          + client.userAgent.minor + " - "
          + client.os.family + " " + client.os.major
          + "." + client.os.minor; 
    }
    return deviceDetails;
}

6. Отправка уведомления о входе

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

Давайте посмотрим на наш метод DeviceService.verifyDevice():

public void verifyDevice(User user, HttpServletRequest request) {
    
    String ip = extractIp(request);
    String location = getIpLocation(ip);

    String deviceDetails = getDeviceDetails(request.getHeader("user-agent"));
        
    DeviceMetadata existingDevice
      = findExistingDevice(user.getId(), deviceDetails, location);
        
    if (Objects.isNull(existingDevice)) {
        unknownDeviceNotification(deviceDetails, location,
          ip, user.getEmail(), request.getLocale());

        DeviceMetadata deviceMetadata = new DeviceMetadata();
        deviceMetadata.setUserId(user.getId());
        deviceMetadata.setLocation(location);
        deviceMetadata.setDeviceDetails(deviceDetails);
        deviceMetadata.setLastLoggedIn(new Date());
        deviceMetadataRepository.save(deviceMetadata);
    } else {
        existingDevice.setLastLoggedIn(new Date());
        deviceMetadataRepository.save(existingDevice);
    }
}

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

private DeviceMetadata findExistingDevice(
  Long userId, String deviceDetails, String location) {
    List<DeviceMetadata> knownDevices
      = deviceMetadataRepository.findByUserId(userId);
    
    for (DeviceMetadata existingDevice : knownDevices) {
        if (existingDevice.getDeviceDetails().equals(deviceDetails) 
          && existingDevice.getLocation().equals(location)) {
            return existingDevice;
        }
    }
    return null;
}

~~ ~»

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

В противном случае мы просто обновляем атрибут lastLoggedIn знакомого устройства.

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

В этой статье мы продемонстрировали, как мы можем отправить уведомление о входе в систему в случае обнаружения незнакомой активности в учетных записях пользователей.