知識庫 / Spring / Spring MVC RSS 訂閱

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

Spring MVC,Spring Security
HongKong
19
02:31 PM · Dec 06 ,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 地址中的國家信息。 使用 GeoLite2 時,我們需要以下 Maven 依賴:

<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. 安全登錄

現在我們已經獲取了用户的默認國家,接下來我們將為認證後的用户添加一個簡單的地理位置檢查器:

@Autowired
private DifferentLocationChecker differentLocationChecker;

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

以下是我們的 不同位置檢查器

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

請注意,我們使用了 setPostAuthenticationChecks(),以確保 檢查僅在成功認證後運行——當用户提供正確的憑據時。

此外,我們的自定義 UnusualLocationException 是一個簡單的 AuthenticationException

我們還需要修改我們的 AuthenticationFailureHandler 以自定義錯誤消息:

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

現在,讓我們深入瞭解一下 isNewLoginLocation() 的實現:

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

請注意,當用户提供正確的憑據時,我們首先會檢查其位置。如果該位置已與該用户賬户關聯,則用户可以成功認證。

如果未關聯,則我們創建一個 NewLocationToken 及其一個已停用的 UserLocation – 以允許用户啓用此新位置。更多信息,請參見後續章節。

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

最後,這裏是簡單的 NewLocationToken 實現——允許用户將新位置關聯到他們的賬户:

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

不同位置登錄監聽器處理我們的事件如下:

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