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