Перейти к основному содержимому

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

· 6 мин. чтения

1. Обзор

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

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

Это часть серии регистрации и, естественно, строится поверх существующей кодовой базы.

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, Long> {
UserLocation findByCountryAndUser(String country, User user);
}

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

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

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

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

@PostMapping("/user/registration")
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:

<dependency>
<groupId>com.maxmind.geoip2</groupId>
<artifactId>geoip2</artifactId>
<version>2.15.0</version>
</dependency>

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

@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];
}
}

Обратите внимание, что мы использовали s etPostAuthenticationChecks() , чтобы проверка запускалась только после успешной аутентификации — когда пользователь предоставляет правильные учетные данные.

Кроме того, наше пользовательское исключение 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<OnDifferentLocationLoginEvent> {

@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);
}
}

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

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

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

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

@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. Ограничения

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

private final String getClientIP(HttpServletRequest request)

не всегда возвращает правильный IP-адрес клиента. Если приложение Spring Boot развернуто локально, возвращаемый IP-адрес (если не настроен иначе) 0.0.0.0. Поскольку этого адреса нет в базе данных MaxMind, регистрация и вход в систему будут невозможны. Та же проблема возникает, если у клиента есть IP-адрес, которого нет в базе данных.

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

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

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