Autoriser l’authentification à partir des emplacements acceptés uniquement avec la sécurité Spring

Autoriser l'authentification à partir des emplacements acceptés uniquement avec Spring Security

1. Vue d'ensemble

Dans ce didacticiel, nous allons nous concentrer sur une fonctionnalité de sécurité très intéressante: sécuriser le compte d'un utilisateur en fonction de son emplacement.

En termes simples,we’ll block any login from unusual or non-standard locations et permet à l'utilisateur d'activer de nouveaux emplacements de manière sécurisée.

Ceci estpart of the registration series et, naturellement, se construit au-dessus de la base de code existante.

2. Modèle d'emplacement utilisateur

Tout d'abord, jetons un œil à notre modèleUserLocation - qui contient des informations sur les emplacements de connexion des utilisateurs; chaque utilisateur a au moins un emplacement associé à son compte:

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

Et nous allons ajouter une opération de récupération simple à notre référentiel:

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

Notez que

  • Le nouveauUserLocation est désactivé par défaut

  • Chaque utilisateur a au moins un emplacement, associé à ses comptes, qui est le premier emplacement où il a accédé à l'application lors de son enregistrement.

3. enregistrement

Voyons maintenant comment modifier le processus d'inscription pour ajouter l'emplacement de l'utilisateur par défaut:

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

Lors de la mise en œuvre du service, nous obtiendrons le pays par l'adresse IP de l'utilisateur:

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

Notez que nous utilisons la base de donnéesGeoLite2 pour obtenir le pays à partir de l'adresse IP. Pour utiliserGeoLite2, nous avions besoin de la dépendance maven:


    com.maxmind.geoip2
    geoip2
    2.9.0

Et nous devons également définir un haricot simple:

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

Nous avons chargé ici la base de donnéesGeoLite2 Country de MaxMind.

4. Connexion sécurisée

Maintenant que nous avons le pays par défaut de l'utilisateur, nous allons ajouter un vérificateur de localisation simple après l'authentification:

@Autowired
private DifferentLocationChecker differentLocationChecker;

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

Et voici nosDifferentLocationChecker:

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

Notez que nous avons utilisé setPostAuthenticationChecks() pour quethe check only run after successful authentication - lorsque l'utilisateur fournit les bonnes informations d'identification.

De plus, notreUnusualLocationException personnalisé est un simpleAuthenticationException.

Nous devrons également modifier nosAuthenticationFailureHandler pour personnaliser le message d'erreur:

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

Examinons maintenant en profondeur l'implémentation deisNewLoginLocation():

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

Remarquez comment, lorsque l'utilisateur fournit les informations d'identification correctes, nous vérifions ensuite leur emplacement. Si l'emplacement est déjà associé à ce compte d'utilisateur, l'utilisateur peut alors s'authentifier avec succès.

Sinon, nous créons unNewLocationToken et unUserLocation désactivé - pour permettre à l'utilisateur d'activer ce nouvel emplacement. Plus à ce sujet, dans les sections suivantes.

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

Enfin, voici l'implémentation simple deNewLocationToken - pour permettre aux utilisateurs d'associer de nouveaux emplacements à leur compte:

@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. Événement de connexion à un emplacement différent

Lorsque l'utilisateur se connecte depuis un emplacement différent, nous avons créé unNewLocationToken et l'avons utilisé pour déclencher unOnDifferentLocationLoginEvent:

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

LeDifferentLocationLoginListener gère notre événement comme suit:

@Component
public class DifferentLocationLoginListener
  implements ApplicationListener {

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

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

Si quelqu'un d'autre tente de se connecter à son compte, il va bien sûr changer son mot de passe. S'ils reconnaissent la tentative d'authentification, ils pourront associer le nouvel emplacement de connexion à leur compte.

6. Activer un nouvel emplacement de connexion

Enfin, maintenant que l'utilisateur a été informé de l'activité suspecte, jetons un œil à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();
}

Et notre méthodeisValidNewLocationToken():

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

En termes simples, nous allons activer lesUserLocationassociés au jeton, puis supprimer le jeton.

7. Conclusion

Dans ce tutoriel, nous nous sommes concentrés sur un nouveau mécanisme puissant pour ajouter de la sécurité dans nos applications -restricting unexpected user activity based on their location.

Comme toujours, l'implémentation complète peut être trouvéeover on GiHub.