1. Overview

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

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:

@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

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:

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:

<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
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.

4. Secure Login

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:

@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.

Also, our custom UnusualLocationException is a simple AuthenticationException.

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

@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:

@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.

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:

@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

When the user login from a different location, we created a NewLocationToken and used it to trigger an 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:

@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

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:

@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.

7. Limitations

To finish the article, we need to mention a limitation of the above implementation. The method we have used to determine the client 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.

8. Conclusion

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.


« 上一篇: Spring YAML配置
» 下一篇: Java遍历Map对象