IP 归属地
这一页讲怎样把调用方的 IP 地址解析成国家、省份、城市与运营商:契约 IIpRegionResolver 与结果 IpRegion,离线库 Provider Security.Ip2Region 的装配与 xdb 文件配置,怎样在操作里拿到调用来源的远端地址去解析,以及它与条件引擎的配合。读完你能在登录日志里记下归属地,并在风控条件里按国家或省份判定。阅读前先读 RBAC 授予与条件与调用上下文。
为什么需要
登录日志要显示「来自广东深圳 电信」,风控规则要写「境外登录需要二次验证」,异常检测要比较两次登录的归属地。这些都需要把 IP 变成地理信息,但在线查询服务会把用户 IP 发到第三方、带来延迟与配额,直接依赖某个库又会让应用和它绑死。框架把归属地做成可选能力 security.ip-region@1:契约只有一个方法,应用依赖契约而不是实现;参考实现 Security.Ip2Region 用离线库在内存里查询,不出网;不装 Provider 时应用照常运行,引用归属地的条件按「取不到」处理。
契约
| 类型 | 说明 |
|---|---|
IIpRegionResolver | Resolve(IPAddress) 返回 Maybe<IpRegion>;查不到、不该查、没有数据都返回无值,不抛出 |
IpRegion | 结果:Country、Province、City、Isp、CountryCode(两位国家代码),各段可能缺失;Location 是展示文本 |
Location 把国家、省份、城市、运营商去掉缺失与重复(不区分大小写)后以空格连接,例如 中国 广东省 深圳市 电信。
宿主可以不装。容器里没有 IIpRegionResolver 时,调用方把归属地当作取不到;条件属性 environment.geo.* 这类引用它的子句为无法判定,IsNull / IsNotNull 照常判定。
| 包 | 清单角色 | 说明 |
|---|---|---|
XiHan.Framework.Security.Ip2Region | provider | ip2region 离线库 Provider,IPv4 与 IPv6 各一个 xdb 文件,首次用到某个地址族时整库读进内存,之后每次查询约百纳秒 |
框架包不带数据文件,xdb 由宿主提供(下载或生成方式见 ip2region 项目)。IPv4 库约 11 MB、IPv6 库约 37 MB。
最小示例
dotnet add package XiHan.Framework.Security.Ip2Region --prereleasexdb 文件随应用发布,例如在项目文件里复制到输出目录:
<ItemGroup>
<None Include="IpDatabases/*.xdb" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>using System.Net;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Hosting.AspNetCore.Endpoints;
using XiHan.Framework.Kernel.Pipeline;
using XiHan.Framework.Profiles.Standard;
using XiHan.Framework.Security.Abstractions.Network;
using XiHan.Framework.Security.Ip2Region;
var builder = WebApplication.CreateBuilder(args);
builder.AddXiHan(xihan => xihan.UseStandardProfile().UseFeature<Ip2RegionSecurityFeature>());
var app = builder.Build();
app.UseXiHan();
// 调用来源的远端地址由 UseXiHan() 写进调用上下文;回环与内网地址解析为无值
app.MapGet("/where", (HttpContext http, IIpRegionResolver regions) =>
{
var origin = http.GetXiHanContext().ValueOrThrow().GetOrigin();
return origin.TryGetValue(out var o)
&& IPAddress.TryParse(o.RemoteAddress, out var address)
&& regions.Resolve(address).TryGetValue(out var region)
? region.Location
: "unknown";
});
app.Run();本机访问 /where 响应 unknown,因为回环地址不查;经反向代理部署并在 UseXiHan() 之前 UseForwardedHeaders() 后,远端地址是真实客户端地址,响应形如 中国 广东省 深圳市 电信。
配置
配置节 XiHan:Security:Ip2Region:
| 键 | 类型 | 缺省 | 说明 |
|---|---|---|---|
V4Database | string | IpDatabases/ip2region_v4.xdb | IPv4 的 xdb 文件;相对路径按 AppContext.BaseDirectory 解析 |
V6Database | string | IpDatabases/ip2region_v6.xdb | IPv6 的 xdb 文件;相对路径同上 |
{
"XiHan": {
"Security": {
"Ip2Region": {
"V4Database": "IpDatabases/ip2region_v4.xdb",
"V6Database": "IpDatabases/ip2region_v6.xdb"
}
}
}
}路径不能为空;文件在首次解析该地址族时才加载,配置错了不会让启动失败,只会让该族一律无值并记一条警告。
在操作里用调用来源解析
调用来源 InvocationOrigin 由宿主在构造调用上下文时写入,context.GetOrigin() 读它;HTTP 宿主填的 RemoteAddress 是连接的对端地址(宿主在 UseXiHan() 之前 UseForwardedHeaders() 时即为转发头改写后的客户端地址,IPv4 映射的 IPv6 还原为 IPv4)。依赖契约而不是 Provider,没装时照常工作:
using System.Net;
using XiHan.Framework.Kernel.Pipeline;
using XiHan.Framework.Primitives;
using XiHan.Framework.Security.Abstractions.Network;
public sealed class CallerLocation(IIpRegionResolver? regions = null)
{
// 没装归属地 Provider、没有调用来源、回环或内网地址、查不到时都返回 null
public string? Describe(InvocationContext context)
{
if (regions is null || !context.GetOrigin().TryGetValue(out var origin))
{
return null;
}
return IPAddress.TryParse(origin.RemoteAddress, out var address) && regions.Resolve(address).TryGetValue(out var region)
? region.Location
: null;
}
}在操作处理器里把它写进登录记录:
using XiHan.Framework.Kernel.Operations;
using XiHan.Framework.Primitives;
[Operation(Id, Expose = Expose.Http, Permission = "sessions.record")]
public sealed class RecordLogin(CallerLocation location, ILoginLog log) : IOperation<RecordLoginRequest, RecordLoginResponse>
{
public const string Id = "sessions.record-login";
public async ValueTask<Result<RecordLoginResponse>> HandleAsync(RecordLoginRequest request, InvocationContext context)
{
// 归属地是展示信息,取不到就记空,不让登录失败
await log.AppendAsync(context.GetSecurity().Principal.Id, location.Describe(context), context.CancellationToken);
return new RecordLoginResponse();
}
}ILoginLog、RecordLoginRequest、RecordLoginResponse 是应用自己的类型。构造函数参数 IIpRegionResolver? regions = null 让容器里没有该服务时也能解析出 CallerLocation;也可以注入 IServiceProvider 再 GetService<IIpRegionResolver>()。
行为与限制
- 不查的地址:回环;IPv4 的
10/8、172.16/12、192.168/16;IPv6 的链路本地、站点本地与组播。它们直接返回无值,不访问库。 - IPv4 映射的 IPv6 地址按 IPv4 查。
- 某个地址族的文件不存在或加载失败时记一条警告,之后该族一律无值,应用照常运行;不会在启动时失败。
- 记录格式兼容两版:新版「国家|省份|城市|运营商|国家代码」,旧版「国家|区域|省份|城市|运营商」。
0与空段为未知;保留地址与各段全未知返回无值。 - 单次查询失败(库损坏、地址超出库的范围)按无值处理,只记调试日志,地址不进日志;
Resolve不抛出。 Ip2RegionResolver是单例且IDisposable,应用停止时释放已加载的查询器;加载后的查询线程安全。- 公开面不暴露第三方类型,换实现不影响调用方。
与条件引擎配合
条件引擎(见 RBAC 授予与条件)的属性由应用的 IConditionAttributeSource 提供。归属地属性约定命名为 environment.geo.*,取不到时返回 ConditionAttributeValue.Missing,子句为无法判定、按不命中计,所以「境外登录」这类规则在没装 Provider 时不会误伤:
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using XiHan.Framework.Security.Abstractions.Network;
public static class GeoAttributes
{
// 给条件属性来源用:没装归属地 Provider 或查不到时返回 null,属性按取不到处理
public static string? CountryCode(IServiceProvider services, IPAddress address)
=> services.GetService<IIpRegionResolver>() is { } resolver && resolver.Resolve(address).TryGetValue(out var region)
? region.CountryCode
: null;
}把 environment.geo.countryCode 登记为 ConditionValueKind.String 属性后,条件 environment.geo.countryCode NotEquals CN 就表示境外来源;来源取不到时子句无法判定,由规则的其他组决定结果。
换成自己的实现
只依赖 Security.Abstractions 的 IIpRegionResolver 契约,实现它并在特性里登记为单例即可换成在线服务或其他离线库;清单声明提供 security.ip-region@1,与 Security.Ip2Region 不同时装配。实现要遵守同样的约定:回环与私网不查、查不到返回无值、不抛出。
常见问题
本机调试为什么总是 unknown
回环与私网地址不查,直接返回无值。调试时用 IPAddress.Parse("...") 传一个公网地址给 Resolve,或者在测试里用 DictionaryConditionAttributeSource 直接给属性值。
部署在反向代理后面,拿到的 IP 是代理的
RemoteAddress 取连接的对端地址。在 UseXiHan() 之前调用 UseForwardedHeaders() 并配置可信代理,远端地址就是 X-Forwarded-For 里的客户端地址,见 ASP.NET Core 宿主。不要自己从请求头读 X-Forwarded-For,未经可信代理过滤的值可以被客户端伪造。
启动时没报错,但所有地址都是无值
多半是 xdb 文件路径不对或文件没随应用发布:文件在首次解析时才加载,缺失只记一条警告。检查日志里 Ip2Region 相关的警告,核对 V4Database / V6Database 相对 AppContext.BaseDirectory 的路径,以及项目文件里的 CopyToOutputDirectory。
数据库怎样更新
替换 xdb 文件后重启应用。库在首次使用时整库读进内存,运行中替换文件不会生效。
gRPC 和 MCP 调用也有远端地址吗
有。它们都经 ASP.NET Core 宿主进入,UseXiHan() 以同样的方式填 InvocationOrigin.RemoteAddress。命令行宿主与进程内调用没有网络对端,RemoteAddress 为 null。
相关包与下一步
- 包参考:Security.Ip2Region、Security.Abstractions
- 规范:RFC-0008《认证与授权》
- 相关章节:调用上下文、RBAC 授予与条件、审计
- 下一步:出站地址策略
