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
@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
Also, our custom
We’ll also need to modify our
@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
@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
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
@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. 結論
在本教程中,我們重點介紹了將安全機制集成到應用程序中的一種強大新方法——基於用户位置限制意外用户活動。