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;
}
...
}
对应的 Repository 接口:
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 定位。需添加 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();
}
4. 安全登录机制
在认证成功后增加位置校验逻辑:
@Autowired
private DifferentLocationChecker differentLocationChecker;
@Bean
public DaoAuthenticationProvider authProvider() {
CustomAuthenticationProvider authProvider = new CustomAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(encoder());
authProvider.setPostAuthenticationChecks(differentLocationChecker);
return authProvider;
}
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];
}
}
关键设计:
- 使用
setPostAuthenticationChecks()
确保校验在认证成功后执行 - 自定义
UnusualLocationException
继承自AuthenticationException
修改认证失败处理器以适配新异常:
@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
createNewLocationToken()
实现:
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. 异常位置登录事件处理
当检测到异常位置登录时,触发 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();
}
服务层验证逻辑:
@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();
}
处理流程:
- 验证 Token 有效性
- 启用对应的
UserLocation
- 清理已使用的 Token
7. 实现局限性
需要特别注意当前实现的限制:获取客户端 IP 的方法:
private final String getClientIP(HttpServletRequest request)
存在以下问题:
- 本地部署时通常返回
0.0.0.0
(未特殊配置时) - 该 IP 不在 GeoLite2 数据库中,会导致注册/登录失败
- 客户端使用数据库中不存在的 IP 时同样会失败
8. 总结
本文实现了一种基于地理位置的用户行为安全控制机制,通过以下方式增强系统安全性:
- 阻止异常位置登录
- 提供安全的新位置启用流程
- 结合邮件通知实现用户自主控制
完整实现代码请参考 GitHub 项目。