Permitir autenticação apenas de locais aceitos com Spring Security

Permitir autenticação apenas de locais aceitos com Spring Security

1. Visão geral

Neste tutorial, vamos nos concentrar em um recurso de segurança muito interessante - proteger a conta de um usuário com base em sua localização.

Basta colocarwe’ll block any login from unusual or non-standard locationse permitir ao usuário habilitar novos locais de forma segura.

Este épart of the registration seriese, naturalmente, se baseia na base de código existente.

2. Modelo de localização do usuário

Primeiro, vamos dar uma olhada em nosso modeloUserLocation - que contém informações sobre os locais de login do usuário; cada usuário tem pelo menos um local associado à sua conta:

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

E vamos adicionar uma operação de recuperação simples ao nosso repositório:

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

Observe que

  • O novoUserLocation é desabilitado por padrão

  • Cada usuário tem pelo menos um local, associado às suas contas, que é o primeiro local que acessou o aplicativo no registro

3. Cadastro

Agora, vamos discutir como modificar o processo de registro para adicionar o local do usuário padrão:

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

Na implementação do serviço, obteremos o país pelo endereço IP do usuário:

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

Observe que estamos usando o banco de dadosGeoLite2 para obter o país a partir do endereço IP. Para usarGeoLite2, precisamos da dependência maven:


    com.maxmind.geoip2
    geoip2
    2.9.0

E também precisamos definir um bean simples:

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

Carregamos o banco de dadosGeoLite2 Country do MaxMind aqui.

4. Login seguro

Agora que temos o país padrão do usuário, adicionaremos um verificador de localização simples após a autenticação:

@Autowired
private DifferentLocationChecker differentLocationChecker;

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

E aqui está nossoDifferentLocationChecker:

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

Observe que usamos setPostAuthenticationChecks() para quethe check only run after successful authentication - quando o usuário fornecer as credenciais corretas.

Além disso, nossoUnusualLocationException personalizado é umAuthenticationException simples.

Também precisaremos modificar nossoAuthenticationFailureHandler para personalizar a mensagem de erro:

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

Agora, vamos dar uma olhada na implementação 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;
}

Observe como, quando o usuário fornece as credenciais corretas, verificamos sua localização. Se o local já estiver associado a essa conta de usuário, ele poderá se autenticar com êxito.

Caso contrário, criamos umNewLocationTokene umUserLocation desabilitado - para permitir que o usuário habilite este novo local. Mais sobre isso, nas seções a seguir.

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

Finalmente, aqui está a implementação simples deNewLocationToken - para permitir que os usuários associem novos locais às suas contas:

@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. Evento de login em local diferente

Quando o usuário faz o login de um local diferente, criamos umNewLocationToken e o usamos para acionar umOnDifferentLocationLoginEvent:

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

ODifferentLocationLoginListener trata nosso evento da seguinte maneira:

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

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

Se outra pessoa tentar fazer login em sua conta, obviamente, mudará sua senha. Se eles reconhecerem a tentativa de autenticação, eles serão capazes de associar o novo local de login à sua conta.

6. Habilitar um novo local de login

Finalmente, agora que o usuário foi notificado sobre a atividade suspeita, vamos dar uma olhada emhow 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();
}

E nosso métodoisValidNewLocationToken():

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

Simplificando, vamos ativar oUserLocation associado ao token e, em seguida, excluir o token.

7. Conclusão

Neste tutorial, nos concentramos em um novo mecanismo poderoso para adicionar segurança aos nossos aplicativos -restricting unexpected user activity based on their location.

Como sempre, a implementação completa pode ser encontradaover on GiHub.