Appearance
C# 怎么获取访客 IP 归属地?.NET 在线接口实现,给登录审计日志打属地标签
接了个政企内部系统的活,审计要求登录日志里带"本次登录来自哪个省市"。翻网上的 C# 教程,前排那篇"史上最全"教的还是淘宝接口——ip.taobao.com 早停服了,照着抄必挂。更麻烦的是 .NET 自带两个坑:Nginx 反代之后取不到真实 IP,取到了也可能是内网地址。
这篇给一套现在还能跑的方案:ASP.NET Core 里调在线 IP 接口,把访客归属地打进登录审计日志。
一、场景:审计日志要"属地",坑在拿不到真实 IP
内部系统、官网后台的登录审计,一般要记:谁、什么时间、从哪个 IP 登录。现在安全和合规的要求更进一步——最好连"从哪个省市登录"也记下来,排查异地登录、账号被盗一眼就能看到。
这个需求在 .NET 里实现,难点不在"查归属地",在"拿不到正确的 IP":
- 反代之后
RemoteIpAddress是内网地址。系统前面挂着 Nginx 或云负载均衡,HttpContext.Connection.RemoteIpAddress拿到的是反代服务器地址(127.0.0.1 或 10.x.x.x)。真实 IP 在X-Forwarded-For头里,但 ASP.NET Core 出于安全默认不信任这个头,必须显式开UseForwardedHeaders并配好可信代理——否则要么拿错 IP,要么被用户塞个伪造头骗过去。 - 拿到 IP 后怎么变省市。老教程的淘宝接口死了,离线库要自己维护更新。省事的是调在线接口:
GET https://ip9.com.cn/get?ip=<ip>,返回 JSON 直接带国家/省份/城市/运营商,免费版免注册,IPv4、IPv6 都支持。
二、方案设计:三层各管一件事
- 取真实 IP:
Program.cs里配置UseForwardedHeaders,把 Nginx 加进可信代理,之后RemoteIpAddress就是访客真实 IP。 - 查归属地:封装
IpGeoService,HttpClient调接口、解析 JSON、结果放IMemoryCache缓存 24 小时——同一 IP 一天最多查一次,省配额也省时间。 - 写审计:登录成功处调一次服务,把省市和运营商拼进审计日志。历史登录数据要补属地,再跑一个限速的批量回填方法。
免费接口限速 60 次/分钟/IP,服务器出口就一个公网 IP,全系统共享额度。所以缓存不是优化是刚需:不做缓存,一次暴力遍历历史日志就能把配额打爆。
三、C# 实现(.NET 8,拼起来就能跑)
1. Program.cs:信任转发头 + 注册服务
csharp
using Microsoft.AspNetCore.HttpOverrides;
using System.Net;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<IpGeoService>(); // HttpClient 走工厂,连接自动管理
var app = builder.Build();
// 让 ASP.NET Core 信任 Nginx/网关转发来的 X-Forwarded-For
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
KnownProxies = { IPAddress.Parse("127.0.0.1") } // 换成 Nginx 真实 IP;云 SLB 填 SLB 的 IP 段
});
app.Run();不配这一段,后面全白费——拿到的永远是反代的内网地址。用云负载均衡的,KnownProxies 要填 SLB 出口 IP 段:填少了拿不到真 IP,填多了等于敞开伪造入口。
2. IpGeoService.cs:查询 + 缓存 + 批量回填
csharp
using System.Net.Http.Json;
using Microsoft.Extensions.Caching.Memory;
public class IpGeoService
{
private readonly HttpClient _http;
private readonly IMemoryCache _cache;
private static readonly TimeSpan CacheTime = TimeSpan.FromHours(24);
public IpGeoService(HttpClient http, IMemoryCache cache)
{
_http = http;
_cache = cache;
}
// 查单个 IP 归属地,带 24 小时缓存;失败返回 null,调用方自行降级
public async Task<IpGeo?> GetAsync(string ip)
{
if (string.IsNullOrWhiteSpace(ip) || ip == "unknown")
return null;
if (_cache.TryGetValue(ip, out IpGeo? hit) && hit != null)
return hit;
IpGeo? geo = null;
try
{
var resp = await _http.GetFromJsonAsync<Ip9Response>(
$"https://ip9.com.cn/get?ip={ip}");
if (resp != null && resp.Ret == 200 && resp.Data != null)
{
var d = resp.Data;
geo = new IpGeo(ip, d.Country ?? "", d.Prov ?? "", d.City ?? "", d.Isp ?? "");
_cache.Set(ip, geo, CacheTime); // 只缓存成功结果,失败下次重试
}
}
catch (HttpRequestException) { /* 超时/限流:返回 null,别让登录接口跟着挂 */ }
catch (TaskCanceledException) { }
return geo;
}
// 批量回填历史登录日志:限速 1.1 秒/个,适配免费接口 60 次/分钟
public async Task BackfillAsync(IEnumerable<string> ips,
Func<string, IpGeo?, Task> onDone)
{
foreach (var ip in ips.Distinct())
{
var geo = await GetAsync(ip);
await onDone(ip, geo);
await Task.Delay(1100);
}
}
}
public record IpGeo(string Ip, string Country, string Prov, string City, string Isp);
public class Ip9Response
{
public int Ret { get; set; }
public Ip9Data? Data { get; set; }
}
public class Ip9Data
{
public string? Country { get; set; }
public string? Prov { get; set; }
public string? City { get; set; }
public string? Isp { get; set; }
}GetFromJsonAsync 默认走 Web JSON 选项(大小写不敏感),ret、prov 这些字段不用加特性也能对上。注意只缓存成功的查询——把 null 也缓存进去的话,接口抖动一次,这个 IP 一天内都查不了。
3. 登录处调用 + 历史数据回填
csharp
app.MapPost("/api/login", async (LoginRequest req, IpGeoService geo,
AuditService audit, HttpContext ctx) =>
{
// UseForwardedHeaders 配好之后,这里就是访客真实 IP
var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var g = await geo.GetAsync(ip);
await audit.WriteLoginAsync(
userId: req.UserId,
ip: ip,
location: g == null ? "未知" : g.Prov + g.City, // 接口失败也不阻塞登录
isp: g?.Isp ?? "");
// ... 正常登录流程
return Results.Ok(new { login = true });
});历史登录表补属地,从库里捞出 distinct 的 IP 调 BackfillAsync,逐条写回:
csharp
var ips = db.LoginLogs.Select(x => x.Ip).ToList(); // 举例
await geo.BackfillAsync(ips, async (ip, g) =>
{
await db.LoginLogs.Where(x => x.Ip == ip)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Location,
g == null ? "未知" : g.Prov + g.City)); // EF Core 7+
});四、落地注意事项
- 可信代理配置是安全项,不是功能项。
KnownProxies留空或配成 0.0.0.0/0,攻击者伪造一个X-Forwarded-For头,审计日志里的属地就全是假的。上线前带伪造头打一下验证。 - 别让查询拖垮主流程。
GetAsync吞掉所有异常返回 null,HttpClient 配好Timeout——登录接口绝不能因为属地查询挂掉,属地只是审计增强,不是登录前置条件。 - 缓存时长跟 IP 变动节奏走。家庭宽带动态 IP 的归属地不频繁漂,24 小时合理;移动网络出口飘得厉害的场景,缩短到 1 小时或只在写审计时查询。
- 配额不够怎么办。"登录时查一次"的场景,免费版 60 次/分钟绰绰有余;一次性回填几十万条历史日志就扛不住了——要么限速慢慢跑,要么升按次计费的 VIP 版(0.05 元/千次起、18 万次/分钟),再往上走私有化部署,政企数据不出内网。
- 日志存 IP 的合规姿势:IP 属个人信息,审计日志属内部安全目的,收集前在隐私政策写明用途,日志访问权限收一收。
总结
.NET 里做 IP 属地,真正的门槛不是"调接口",是前面几步:转发头信不信任、可信代理怎么配、缓存怎么做、失败怎么降级。这四件事理顺,一个 IpGeoService 就能让登录审计、异地登录提醒、按地域排障共用同一份数据。
在线接口 https://ip9.com.cn/get?ip=<ip> 免注册直接调,返回国家/省市/运营商/经纬度,IPv4 和 IPv6 都支持,字段和额度说明见官网 https://www.ip9.com.cn 。先把转发头配对,再谈别的。