Allow Authentication from Accepted Locations Only with Spring Security – 使用Spring Security只允许从接受的地点进行认证

最后修改: 2017年 6月 22日

中文/混合/英文(键盘快捷键:t)

1. Overview

1.概述

In this tutorial, we’ll focus on a very interesting security feature – securing the account of a user based on their location.

在本教程中,我们将重点讨论一个非常有趣的安全功能–根据用户的位置来保护其账户。

Simply put, we’ll block any login from unusual or non-standard locations and allow the user to enable new locations in a secured way.

简单地说,我们将阻止任何来自不寻常或非标准地点的登录,并允许用户以安全的方式启用新地点。

This is part of the registration series and, naturally, builds on top of the existing codebase.

这是注册系列的一部分,自然是建立在现有代码库的基础上。

2. User Location Model

2.用户位置模型

First, let’s take a look at our UserLocation model – which holds information about the user login locations; each user has at least one location associated with their account:

首先,让我们看一下我们的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;
    }
    ...
}

And we’re going to add a simple retrieval operation to our repository:

我们要给我们的存储库添加一个简单的检索操作。

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

Note that

请注意

  • The new UserLocation is disabled by default
  • Each user has at least one location, associated with their accounts, which is the first location they accessed the application on registration

3. Registration

3.注册

Now, let’s discuss how to modify the registration process to add the default user location:

现在,让我们讨论一下如何修改注册过程以添加默认的用户位置。

@PostMapping("/user/registration")
public GenericResponse registerUserAccount(@Valid UserDto accountDto, 
  HttpServletRequest request) {
    
    User registered = userService.registerNewUserAccount(accountDto);
    userService.addUserLocation(registered, getClientIP(request));
    ...
}

In the service implementation, we’ll obtain the country by the IP address of the user:

在服务实现中,我们将通过用户的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);
}

Note that we’re using the GeoLite2 database to get the country from the IP address. To use GeoLite2 , we needed the maven dependency:

请注意,我们使用GeoLite2数据库来从IP地址获取国家。为了使用GeoLite2,我们需要maven的依赖。

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

And we also need to define a simple bean:

而且我们还需要定义一个简单的bean。

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

We’ve loaded up the GeoLite2 Country database from MaxMind here.

我们已经从MaxMind这里加载了GeoLite2国家数据库。

4. Secure Login

4.安全登录

Now that we have the default country of the user, we’ll add a simple location checker after authentication:

现在我们有了用户的默认国家,我们将在认证后添加一个简单的位置检查器。

@Autowired
private DifferentLocationChecker differentLocationChecker;

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

And here is our DifferentLocationChecker:

这里是我们的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];
    }
}

Note that we used setPostAuthenticationChecks() so that the check only run after successful authentication – when user provide the right credentials.

请注意,我们使用了setPostAuthenticationChecks(),这样检查只在认证成功后运行–当用户提供正确的凭证时。

Also, our custom UnusualLocationException is a simple AuthenticationException.

另外,我们的自定义UnusualLocationException是一个简单的AuthenticationException

We’ll also need to modify our AuthenticationFailureHandler to customize the error message:

我们还需要修改我们的AuthenticationFailureHandler以定制错误信息。

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

Now, let’s take a deep look at the isNewLoginLocation() implementation:

现在,让我们深入了解一下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;
}

Notice how, when the user provides the correct credentials, we then check their location. If the location is already associated with that user account, then the user is able to authenticate successfully.

请注意,当用户提供了正确的凭证后,我们就会检查他们的位置。如果该位置已经与该用户账户相关联,那么该用户就能够成功地进行认证。

If not, we create a NewLocationToken and a disabled UserLocation – to allow the user to enable this new location. More on that, in the following sections.

如果不是,我们创建一个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);
}

Finally, here’s the simple NewLocationToken implementation – to allow users to associate new locations to their account:

最后,这里是简单的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. Different Location Login Event

5.不同地点的登录事件

When the user login from a different location, we created a NewLocationToken and used it to trigger an OnDifferentLocationLoginEvent:

当用户从不同地点登录时,我们创建了一个NewLocationToken,并使用它来触发OnDifferentLocationLoginEvent

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

The DifferentLocationLoginListener handles our event as follows:

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

Note how, when the user logs in from a different location, we’ll send an email to notify them.

请注意,当用户从不同地点登录时,我们将发送电子邮件通知他们

If someone else attempted to log into their account, they’ll, of course, change their password. If they recognize the authentication attempt, they’ll be able to associate the new login location to their account.

如果有其他人试图登录他们的账户,他们当然会改变密码。如果他们认出了这个认证尝试,他们就能将新的登录位置与他们的账户联系起来。

6. Enable a New Login Location

6.启用一个新的登录位置

Finally, now that the user has been notified of the suspicious activity, let’s have a look at 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();
}

And our isValidNewLocationToken() method:

还有我们的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();
}

Simply put, we’ll enable the UserLocation associated with the token and then delete the token.

简单地说,我们将启用与令牌相关的UserLocation,然后删除该令牌。

7. Limitations

7.7.局限性

To finish the article, we need to mention a limitation of the above implementation. The method we have used to determine the client IP:

在文章的最后,我们需要提到上述实现的一个限制。我们用来确定客户端IP的方法。

private final String getClientIP(HttpServletRequest request)

does not always return the client’s correct IP address. If the Spring Boot application is deployed locally, the returned IP address is (unless configured differently) 0.0.0.0. As this address is not present in the MaxMind database, registration and login won’t be possible. The same problem occurs if the client has an IP address that is not present in the database.

并不总是返回客户端的正确IP地址。如果Spring Boot应用程序部署在本地,返回的IP地址是(除非配置不同)0.0.0.0。由于这个地址不存在于MaxMind数据库中,所以注册和登录将无法进行。如果客户端的IP地址不在数据库中,也会出现同样的问题。

8. Conclusion

8.结论

In this tutorial, we focused on a powerful new mechanism to add security into our applications – restricting unexpected user activity based on their location.

在本教程中,我们重点讨论了一个强大的新机制,以在我们的应用程序中添加安全性–根据用户的位置限制意外的用户活动。

As always, the full implementation can be found over on GiHub.

一如既往,完整的实施方案可以在GiHub上找到