Skip to content

C# 怎么获取访客 IP 归属地?.NET 在线接口实现,给登录审计日志打属地标签

接了个政企内部系统的活,审计要求登录日志里带"本次登录来自哪个省市"。翻网上的 C# 教程,前排那篇"史上最全"教的还是淘宝接口——ip.taobao.com 早停服了,照着抄必挂。更麻烦的是 .NET 自带两个坑:Nginx 反代之后取不到真实 IP,取到了也可能是内网地址。

这篇给一套现在还能跑的方案:ASP.NET Core 里调在线 IP 接口,把访客归属地打进登录审计日志。

一、场景:审计日志要"属地",坑在拿不到真实 IP

内部系统、官网后台的登录审计,一般要记:谁、什么时间、从哪个 IP 登录。现在安全和合规的要求更进一步——最好连"从哪个省市登录"也记下来,排查异地登录、账号被盗一眼就能看到。

这个需求在 .NET 里实现,难点不在"查归属地",在"拿不到正确的 IP":

  1. 反代之后 RemoteIpAddress 是内网地址。系统前面挂着 Nginx 或云负载均衡,HttpContext.Connection.RemoteIpAddress 拿到的是反代服务器地址(127.0.0.1 或 10.x.x.x)。真实 IP 在 X-Forwarded-For 头里,但 ASP.NET Core 出于安全默认不信任这个头,必须显式开 UseForwardedHeaders 并配好可信代理——否则要么拿错 IP,要么被用户塞个伪造头骗过去。
  2. 拿到 IP 后怎么变省市。老教程的淘宝接口死了,离线库要自己维护更新。省事的是调在线接口:GET https://ip9.com.cn/get?ip=<ip>,返回 JSON 直接带国家/省份/城市/运营商,免费版免注册,IPv4、IPv6 都支持。

二、方案设计:三层各管一件事

  1. 取真实 IPProgram.cs 里配置 UseForwardedHeaders,把 Nginx 加进可信代理,之后 RemoteIpAddress 就是访客真实 IP。
  2. 查归属地:封装 IpGeoServiceHttpClient 调接口、解析 JSON、结果放 IMemoryCache 缓存 24 小时——同一 IP 一天最多查一次,省配额也省时间。
  3. 写审计:登录成功处调一次服务,把省市和运营商拼进审计日志。历史登录数据要补属地,再跑一个限速的批量回填方法。

免费接口限速 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 选项(大小写不敏感),retprov 这些字段不用加特性也能对上。注意只缓存成功的查询——把 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 。先把转发头配对,再谈别的。