僅允許從受信任位置進行身份驗證(Spring Security)

Spring MVC,Spring Security
Remote
1
12:13 AM · Nov 30 ,2025

1. 概述

在本教程中,我們將重點介紹一項非常有趣的安全性功能——根據用户位置來保護用户賬户。

簡單來説,我們將阻止來自異常或非標準位置的任何登錄,並允許用户以安全的方式啓用新的位置。

這屬於註冊系列的一部分,並且自然地建立在現有代碼庫之上。

2. 用户位置模型首先,讓我們來查看我們的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;
    }
    ...
}

並且我們將為我們的存儲庫添加一個簡單的檢索操作:

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

請注意,

  • 新的UserLocation默認被禁用
  • 每個用户至少有一個位置,與他們的賬户關聯,這是他們在註冊時首次訪問應用程序的位置

3. 註冊

現在,我們來討論如何修改註冊過程以添加默認用户位置:

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

在服務實現中,我們將通過用户的 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);
}

請注意,我們使用 GeoLite2 數據庫從 IP 地址獲取國家:

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

並且我們需要定義一個簡單的 Bean:

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

我們已從 MaxMind 加載 GeoLite2 Country 數據庫。

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. 不同位置登錄事件

當用户從不同位置登錄時,我們創建了一個 NewLocationToken並使用它來觸發一個 OnDifferentLocationLoginEvent:

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

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

注意,當用户從不同位置登錄時,我們將向他們發送一封電子郵件以通知他們

如果有人試圖登錄他們的帳户,他們當然會更改密碼。如果他們認識到身份驗證嘗試,他們將能夠將新的登錄位置關聯到他們的帳户。

6. 啓用新的登錄位置

最後,在用户已收到可疑活動通知後,讓我們看看應用程序如何處理啓用新的位置:

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

以及我們的 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();
}

簡單來説,我們將啓用與令牌關聯的 UserLocation,然後刪除該令牌。

7. 侷限性

為了完成這篇文章,我們需要提及上述實現的侷限性。我們用來確定客户端 IP 的方法:

private final String getClientIP(HttpServletRequest request)

並不總是返回客户端的正確 IP 地址。如果 Spring Boot 應用程序部署在本地,返回的 IP 地址(除非另有配置)是 0.0.0.0。由於此地址不在 MaxMind 數據庫中,註冊和登錄將無法進行。同樣的問題也發生在客户端具有不在數據庫中 IP 地址的情況下。

8. 結論

在本教程中,我們重點介紹了將安全機制集成到應用程序中的一種強大新方法——基於用户位置限制意外用户活動。

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.