登录与安全审计:Sa-Token 会话、微信登录与 ip2region 登录地

悦目图库

在大多数用户眼里,登录就是"输入账号密码,进去了"。但对一个正经的产品来说,登录是一整套体系:会话怎么建立、怎么续期、怎么在多设备间共存;登录这件事本身要怎么记、记什么、记完怎么用——比如你在新设备上登录,手机收到一条"新设备登录提醒",这个提醒背后就是一整套登录审计。

这篇把登录从"对密码"拆到"审计闭环"。主角是 Sa-Token(一个轻量鉴权框架)和一套完整的登录记录体系:一次登录记下 IP、设备、城市、风险等级,还异步推送分级通知。你会发现,登录的功夫不在"验密码"那一下,而在验完之后那一片。

会话,不是 cookie,是状态管理

先问个基础问题:用户登录后,怎么知道"他是他"?传统做法是 session + cookie:服务器存一份会话,把会话 ID 塞进 cookie,浏览器每次请求带上。这套东西本身没问题,但问题在"分布式"——多台服务器时,会话存在哪台机器上?这就是为什么这个项目用 Sa-Token 配 Redis:会话状态不落单机,落共享存储。

// 用户服务里,登录的核心就一行
StpUtil.login(user.getId());
// 之后任何地方都能拿到当前用户
Long userId = StpUtil.getLoginIdAsLong();
boolean isLogin = StpUtil.isLogin();

StpUtil 是 Sa-Token 的门面,登录、登出、拿当前用户、判断是否登录,全在它身上。背后它维护的是一个"token ↔ 用户 ID"的映射,token 存 Redis(sa-token-redis-jackson 集成),默认通过响应头或 cookie 传给前端。前端 request.tswithCredentials: true 就是为了让 cookie 里的 token 每次请求都带上。

为什么用 Sa-Token 而不是 Spring Security?这是个很实际的选择题。Spring Security 功能全,但配置复杂——滤器链、SecurityContext、方法级安全,全是概念,对一个小站来说是杀鸡用牛刀。Sa-Token 主打"轻量、易用",一行 StpUtil.login 就是登录,一个注解 @SaCheckLogin 就是鉴权,@SaCheckRole("admin") 就是角色校验。它把"鉴权"这个高频需求压到了最低成本。

更重要的是,Sa-Token 的会话天然和 Redis 绑定——token 存 Redis,天然支持多实例共享。这比"把 session 存在单机内存"的方案高了一个维度,也是它能扛住集群部署的原因。选鉴权框架,不是选功能最全的,是选"登录这个场景最顺手的"

Sa-Token 会话架构图

再往深一层看 Sa-Token 的会话模型。StpUtil.login 背后发生的是:生成一个 token,把"token ↔ 用户 ID"写进 Redis,token 通过响应返回给前端。之后每次请求,前端带上 token,后端从 Redis 查"这个 token 对应谁"。这套"无状态 token + Redis 映射"的好处是:任何一台服务器都能验证任何一次请求——token 查 Redis 就能拿到用户,不依赖请求落在哪台机器上。这就是会话能扛集群部署的根本原因。

还有 @SaCheckLogin 这种注解。控制器方法上挂一个 @SaCheckLogin,Sa-Token 的拦截器会在方法执行前自动校验"是否登录",没登录直接抛异常、返回 401。这比在每个方法里手写 if (!StpUtil.isLogin()) return ... 干净一个量级。声明式鉴权,是把"鉴权"从业务代码里抽出去的钥匙——业务方法只管自己的逻辑,鉴权是框架层面的事。

这个项目里 @SaCheckRole@SaCheckPermission 用得很广——@SaCheckRole("admin") 是角色级(管理端接口),@SaCheckPermission("picture:upload") 是权限点级(具体到某个操作)。角色和权限的粒度不同:角色是"你是谁",权限是"你能干什么"。用角色管管理端(粗粒度、人少),用权限管用户操作(细粒度、人多),是这两套注解最常见的分工。还有一个 HttpRequestWrapperFilter 会做敏感请求的包装——把请求体里的敏感字段(比如密码)在进入业务代码前处理,避免敏感信息泄露到日志或审计里。鉴权只解决"能不能进",敏感数据处理解决"进来了别留痕",两层都得有。

最后补一句 Sa-Token 的生态:它不只是登录,还管在线统计、会话封禁、账号封禁——StpUtil.disable(id) 能让一个账号直接无法登录,管理员一句调用就封停违规账号,配合多设备踢下线,一个账号的"能登、能在哪登、能不能登"就全在框架手里了。这些能力让"封号"从运营操作变成了框架调用,是社区类产品的刚需,也是选一个成熟鉴权框架的隐性收益——你以为你只是选了个登录,其实顺带拿到了整个会话管理工具箱。

一次登录,六条记录

真正让这个登录体系区别于"能用就行"的,是 recordLogin 这个方法——一次成功的登录,它记下六样东西:

public Long recordLogin(User user, String loginMethod, HttpServletRequest request) {
    // 1. 客户端 IP
    String loginIp = ServletUtils.getClientIP(request);
    // 2. 设备信息(从 User-Agent 解析)
    DeviceInfoUtil.DeviceInfo deviceInfo = DeviceInfoUtil.parseDeviceInfo(request);
    // 3. 地理位置(ip2region 离线定位)
    String loginLocation = RegionUtils.getCityInfo(loginIp);

    UserLoginRecord loginRecord = new UserLoginRecord();
    loginRecord.setUserId(user.getId());
    loginRecord.setLoginTime(new Date());
    loginRecord.setLoginIp(loginIp);
    loginRecord.setLoginLocation(loginLocation);
    loginRecord.setDeviceType(deviceInfo.getDeviceType());
    loginRecord.setDeviceName(deviceInfo.getDeviceName());
    loginRecord.setOsType(deviceInfo.getOsType());
    loginRecord.setOsVersion(deviceInfo.getOsVersion());
    loginRecord.setBrowserType(deviceInfo.getBrowserType());
    loginRecord.setBrowserVersion(deviceInfo.getBrowserVersion());
    loginRecord.setUserAgent(deviceInfo.getUserAgent());
    loginRecord.setLoginStatus(1); // 成功
    loginRecord.setLoginMethod(loginMethod);
    loginRecord.setSessionId(request.getSession().getId());

    // 4. 风险等级
    int riskLevel = detectLoginRisk(user.getId(), loginIp, deviceInfo.getDeviceType());
    loginRecord.setRiskLevel(riskLevel);
    if (riskLevel > 0) {
        loginRecord.setRiskReason(getRiskReason(riskLevel, loginIp, loginLocation));
    }

    // 5. 保存
    this.save(loginRecord);
    // 6. 异步发送登录通知
    sendLoginNotification(loginRecord);
    return loginRecord.getId();
}

登录审计六条记录流程图

IP、设备、城市、风险、通知,一次登录全记全推。这六样里,IP 和城市是"在哪登的",设备是"用什么登的",风险是"这次登录可疑吗",通知是"让用户知道"。凑在一起,一份登录记录就成了一个完整的"登录事件快照"。

为什么要记这么多?两个用途:用户侧——登录记录页展示"时间/IP/地点/设备",用户能自己判断"这登的不是我";安全侧——审计日志是发现异常登录、暴力破解的第一手素材。登录日志不是"记着好看",是"出事能查、异常能看"。

这里还有个容易忽略的字段:loginMethod——记录这次登录用的是哪种方式(密码、微信验证码、后台手动登录等)。为什么连登录方式都要记?因为不同方式的信任等级不同:密码登录可能被撞库,微信验证码登录绑定微信身份相对可信。审计时看到"这个账号最近都是用微信登的,突然来了一次密码登录",本身就是一个值得警惕的信号。登录方式的维度,是审计里常被忽略、但很有信息量的一栏

还有 sessionId——把这次登录对应的会话 ID 也记下来。它的价值在于"事件关联":以后要排查"这个会话做过什么",从登录记录就能定位到它是哪次登录建立的。登录记录不只是"登了一次",它是这个账号整个生命周期的"入口档案"。

设备指纹:从 User-Agent 里抠出你的手机型号

DeviceInfoUtil.parseDeviceInfo 是个容易被低估的工具——它从 User-Agent 字符串里解析出设备类型、设备名、操作系统、浏览器:

DeviceInfoUtil.DeviceInfo deviceInfo = DeviceInfoUtil.parseDeviceInfo(request);
// → deviceType: "Mobile" / "Desktop" / "Tablet"
// → deviceName: "iPhone 15 Pro" / "Pixel 8"
// → osType: "iOS" / "Android" / "Windows"
// → osVersion: "17.2" / "14"
// → browserType: "Chrome" / "Safari"
// → browserVersion: "131.0"

User-Agent 是浏览器每次请求都会带的"自我介绍",里面藏着操作系统、浏览器、甚至设备型号。parseDeviceInfo 就是一系列模式匹配——命中"iPhone"就是 iOS 手机,命中"Chrome"就是浏览器版本。这套解析不依赖任何外部服务,纯本地字符串匹配。

设备信息在风险检测里是核心依据——后面你会看到,"常用设备"就是靠 deviceType 判断的。同一台手机登录了十次是正常,换一台新设备登录就可能是风险。设备指纹就是"你是谁"的一个稳定标识。

但设备指纹有个天然局限:User-Agent 是能伪造的。写个爬虫改一下 User-Agent,就能伪装成手机或者桌面。所以这里的设备信息只用于"行为基线的粗略比对",不作为唯一的强校验依据——它是"这台设备常用吗"的参考,而不是"你是谁"的铁证。安全系统里的每一个信号,都要知道它的置信度:IP 会漂移(移动网络、代理),UA 能伪造,真正可靠的还是"多信号叠加"。

还有个细节:parseDeviceInfo 解析出的 deviceName(比如"iPhone 15 Pro")在登录记录页上会展示出来——用户一眼看到"哦,是我这台手机",比只显示一个设备类型更有说服力。审计数据不仅要记,还要以用户能懂的方式展示,否则记录再全,用户也只会觉得"看不懂,算了"。

ip2region:离线把 IP 变成城市

IP 定位通常想到的是"调一个 IP 库 API"。但这个项目用的是 ip2region——一个离线的 IP 库,xdb 文件在启动时加载进内存,查询零外部调用:

// 启动时,把 ip2region.xdb 加载进内存
byte[] cBuff = Searcher.loadContentFromFile(dbPath);
SEARCHER = Searcher.newWithBuffer(cBuff); // 基于内存的查询对象

// 查询时,纯本地计算
public static String getCityInfo(String ip) {
    String region = SEARCHER.search(ip);
    // → "中国|广东|广州" 这样的格式
    return parseCity(region);
}

为什么不用在线 API?三个原因。——内存查询是微秒级,在线 API 是一次网络往返,登录这种高频操作加不起;——不依赖第三方服务的可用性,ip2region 挂了不影响登录;——IP 库文件一次下载,终身使用,没有按次计费。离线定位对"登录记录"这种场景是完美的——它不需要 100% 精确,只要"这个 IP 大概在哪个城市"就够用户判断了。

实现上有处细节:xdb 文件放在 classpath(/ip2region.xdb),启动时先拷到临时目录再加载。为什么要多此一举?因为 classpath 里的资源是只读的,而 ip2region 的加载方式要求从文件路径读——所以先 FileUtils.writeFromStream 拷出来,再按路径加载。这个"从 classpath 拷贝到可读路径"的小动作,是很多资源加载类工具的通病解法。

ip2region 的查询结果是"中国|广东|广州"这种国家/省/市格式,getCityInfo 会把它解析成用户能读的城市名。这里还有个防御细节:解析前先 ip.trim() 去掉空格,查询失败时返回"未知",绝不因为一条脏 IP 让整个登录记录流程崩溃。登录记录是"尽力而为"的审计——IP 查不到城市,就记"未知",不影响登录本身。

为什么 ip2region 的数据够用?因为它对"登录地"这种场景是精度过剩的——用户只需要知道"这个 IP 大概在哪个城市",来判断"是不是我在登"。城市级精度对这个用途绰绰有余,而 ip2region 的城市级定位是免费的、离线的、微秒级的。给一个"够用就行"的场景配一个"恰到好处"的工具,比给最好的工具更聪明——在线高精度 IP 库对登录审计来说是浪费。

风险检测:新 IP 加新设备,就是高危

登录记录最值钱的部分是 detectLoginRisk——它判定这次登录的风险等级,依据是最近的登录历史:

public int detectLoginRisk(Long userId, String loginIp, String deviceInfo) {
    // 取最近 10 次成功登录
    List<UserLoginRecord> recentLogins = ...; // 按时间倒序,LIMIT 10

    if (recentLogins.isEmpty()) {
        return 0; // 首次登录,正常
    }

    // 是不是常用 IP?
    boolean isCommonIp = recentLogins.stream()
            .anyMatch(record -> loginIp.equals(record.getLoginIp()));
    // 是不是常用设备?
    boolean isCommonDevice = recentLogins.stream()
            .anyMatch(record -> deviceInfo.equals(record.getDeviceType()));

    // 1 小时内登录了几次?
    long recentLoginCount = recentLogins.stream()
            .filter(r -> now - r.getLoginTime() < 3600000)
            .count();

    if (!isCommonIp && !isCommonDevice) return 2; // 高危:新IP且新设备
    else if (!isCommonIp || !isCommonDevice) return 1; // 可疑:新IP或新设备
    else if (recentLoginCount > 5) return 1; // 可疑:频繁登录
    return 0; // 正常
}

风险检测与分级逻辑图

判定逻辑朴素但有效:常用 IP + 常用设备 = 正常;新 IP 或新设备 = 可疑;新 IP 加新设备 = 高危;1 小时内登录超过 5 次 = 可疑(可能是暴力破解或者账号被盗后疯狂试探)。

这个设计的精髓是"行为基线"——它不假设"登录就是对的",而是拿"这次登录"和"用户的历史行为"比。同一台手机在同一个城市登录,正常;半夜从地球另一端用一台没见过的设备登录,高危。安全检测的本质是找异常,而异常的定义来自习惯——这套"基线 + 偏差"的思路,比"黑名单"高级得多。

LIMIT 10 这个窗口也值得说:为什么只取最近 10 次登录做基线,而不是全部历史?因为基线要"近"——半年前登录的 IP 和现在没关系了,取最近 10 次刚好覆盖"最近常用的 IP 和设备"。窗口太大,老数据会稀释当前习惯的权重;窗口太小,一次出差换个 IP 就把基线搞乱了。10 次是这个场景下"近且稳"的折中。

风险检测的判定还有一层"误报率"的考虑:新 IP 新设备直接标高危,会不会误伤?会——用户换了新手机、换了网络,第一次登录就可能被标高危。但这就是审计的设计取舍:宁可偶尔误报,不可漏掉真风险。被标了高危的用户收到一条"异常登录提醒",知道自己刚换手机,点掉就是;真正被撞库的用户收到提醒,就能立刻改密码。误报的成本是"一条多余的通知",漏报的成本是"账号被盗"。这笔账,安全系统永远选后者。

登录失败也要记,还标高危

只记成功登录是不完整的——失败的登录往往更值钱recordLoginFailure 专门记失败:

public void recordLoginFailure(Long userId, String loginMethod, HttpServletRequest request, String failReason) {
    UserLoginRecord loginRecord = new UserLoginRecord();
    loginRecord.setUserId(userId);
    loginRecord.setLoginIp(loginIp);
    loginRecord.setLoginLocation(loginLocation);
    loginRecord.setDeviceType(deviceInfo.getDeviceType());
    // ...
    loginRecord.setLoginStatus(0); // 失败
    loginRecord.setRiskLevel(2); // 失败登录直接标高危
    loginRecord.setRiskReason(failReason);
    this.save(loginRecord);
}

失败登录直接标成高危(riskLevel=2),因为失败的登录背后大概率不是本人——是密码输错忘了?是号被盗了在试密码?还是暴力破解脚本在扫?不管哪种,把它标高危、留 reason,至少给后续分析留了线索。一个"连续失败记录"的账号,配合 detectLoginRisk 的频繁登录判定,就能被安全系统盯上。

失败记录里也有个细节也记录了 IP 和设备。为什么?因为如果发现"某个 IP 对一批账号发起失败登录",那就是暴力破解的信号源——单条失败记录看不出什么,但按 IP 聚合后,攻击模式就浮现了。审计数据单条没用,聚合才有用——这也是为什么每条记录都尽量记全字段。

失败的登录记录还有个隐性的价值:它是"账号被尝试攻击"的最早信号。一个账号平时每天都用,突然某天晚上出现连续 10 次失败——这几乎可以断定是撞库或者暴力破解。虽然单次失败看不到意义,但"按账号聚合失败次数 + 时间"之后,攻击模式立刻浮现。所以失败记录不只是"记下来",它是安全检测的原料,和 detectLoginRisk 的"频繁登录"判定正好配套。

这里还有个容易被忽视的点:失败记录故意不记密码本身。无论失败还是成功,登录记录里都没有密码字段——因为审计日志不该承担"存储敏感凭证"的责任,密码进日志是安全事故的隐患。审计字段的选择,本身就是一次安全决策:该记的记全,不该记的一律不记。

分级通知:你在新设备登录了

登录记录不只躺在数据库里,还要主动通知用户。sendLoginNotification 按风险等级发不同的通知:

if (loginRecord.getRiskLevel() == 2) {
    title = "账号安全提醒:检测到异常登录";
    icon = "alert";
} else if (loginRecord.getRiskLevel() == 1) {
    title = "登录提醒:新设备登录";
    icon = "announce";
} else {
    title = "登录成功通知";
    icon = "info";
}

风险等级决定通知文案:高危发"账号安全提醒",可疑发"新设备登录",正常发"登录成功"。这个分级让用户一眼判断"该不该紧张"——正常登录的通知是安静的通知,异常登录的通知是带警示的通知。

这个功能是"登录记录"从"被动审计"到"主动防御"的转折点:记录只是知道"发生了什么",通知是让"该知道的人知道"。用户在自己没登录时收到"新设备登录"提醒,第一反应就是去改密码——这比任何事后追查都有效。登录审计的闭环,在这里真正合上了。

通知的发送是异步的——sendLoginNotification 不会阻塞登录请求本身。登录要快,用户点完登录按钮不想等一个通知发出去;通知是后台动作,晚几秒到没关系。这个"核心流程和外围动作分离"的设计,和前面几篇里"非核心失败要静默降级"是同一个原则:登录是主流程,通知是锦上添花,主流程不能被花拖慢。

通知不是只发一种渠道——它进消息中心(用户站内的通知列表),也可能走其他推送。分级的作用就在这里:高危通知要醒目(警示图标),正常通知只要安静到达。通知的分级,本质是把"信息的重要性"翻译成"展示的紧迫性"——用户扫一眼就知道该不该紧张,而不是所有通知一个样。

通知的消息体里还有个细节:它会把这次登录的 IP、地点、时间都带上——"您于 14:32 在广东广州登录(IP 113.xx.xx.xx)"。为什么通知要带这些细节?因为光说"新设备登录"用户没法判断"是不是我",带上地点和时间,用户才能核对自己刚才是不是真的在那个时间登过。通知的价值,在于让接收者能做出判断——一个不能用于判断的通知,只是个打扰。

多设备登录:切面管住"同一账号几台设备"

很多产品有个设置项:"允许同一账号在多台设备同时登录吗"。这个项目的实现藏在 AOP 切面里:

@Around("execution(* ...UserServiceImpl.userLogin(..))")
public Object handleMultiDeviceLogin(ProceedingJoinPoint joinPoint) throws Throwable {
    Object result = joinPoint.proceed(); // 先执行原始登录

    if (StpUtil.isLogin()) {
        Long userId = StpUtil.getLoginIdAsLong();
        String currentToken = StpUtil.getTokenValue();
        Integer allowMultiDeviceLogin = userService.getUserMultiDeviceLogin(userId);
        // 如果不允许多设备,就把之前的 token 都踢下线,只留当前这个
        if (allowMultiDeviceLogin != null && allowMultiDeviceLogin == 0) {
            StpUtil.logoutByLoginId(userId); // 踢掉其他设备
            StpUtil.login(userId);           // 重新登录,只留当前
        }
    }
    return result;
}

用 AOP 环绕登录方法而不是在登录方法里写死逻辑,好处是解耦——"是否允许多设备"是用户的设置,登录方法本身不需要知道。切面在登录完成后查设置,不允许就把旧 token 全部踢下线、重新登录当前这台。这样"多设备控制"变成了一个可插拔的横切逻辑,和登录主流程完全隔离。

StpUtil.logoutByLoginId(userId) 是 Sa-Token 的一个强大能力——可以指定踢掉某个用户的所有会话。这在安全场景极有价值:账号被盗,管理员可以一键把该用户所有设备的会话作废。会话管理不只是"登录/登出",还有"远程踢下线"这种运维能力。

多设备控制还有个前端配合的细节:当用户被踢下线(比如另一个设备登录把他挤掉了),下次他操作时请求会返回"未登录",前端 request.ts 的拦截器会把他引导回登录页。这个"被踢后的体验"也值得设计——不是直接 401 一脸懵,而是让用户知道"你的会话失效了,需要重新登录"。安全动作的副作用,也要在用户体验上闭环,否则用户会以为系统坏了。

这里还有个策略选择:allowMultiDeviceLogin 是用户自己设的。允许,就是"多设备同时在线"(默认);不允许,就是"新登录顶掉旧登录"(类似很多游戏账号的规则)。这个设置放在用户手里,而不是产品写死,是因为"一个账号几个设备同时登"对不同用户的意义不同——有人需要手机电脑同时用,有人希望账号只能一处登录更安全。安全策略的粒度,能下放到用户就下放到用户,产品只负责提供选项。

微信登录:公众号里的 6 位验证码

这个站还有微信登录——不是网页扫码,而是公众号里的验证码登录。用户关注公众号,回复一个指令,公众号回一个 6 位验证码,用户在网页输入,就登录了。核心在 WxLoginManager

public String generateLoginCode(String openId) {
    String code = RandomUtil.randomNumbers(6); // 6位随机数字
    // 验证码存 Redis,5分钟过期
    stringRedisTemplate.opsForValue().set(
        OPENID_KEY_PREFIX + code, openId,
        CODE_EXPIRATION_TIME, TimeUnit.SECONDS);
    return code;
}

微信验证码登录时序图

流程是:用户回复指令 → 公众号服务端 WxController 生成一个 6 位验证码并和用户的 openId 绑定存进 Redis → 网页端拿到验证码 → 调 userLoginByWxCode → 用验证码换 openId → StpUtil.login。验证码 5 分钟过期,一个验证码只能用一次(取完即删),openId 是微信用户在这个公众号下的唯一标识。

WxController 还处理公众号的 XML 消息——微信公众平台的消息是 XML 格式,回复也是 XML。这又是一个"协议适配"的细节:公众号消息的收发格式和普通接口完全不同,produces = "application/xml;charset=UTF-8" 告诉微信"我回的是 XML"。对接微信生态,一半的功夫花在适配它的消息协议上

为什么用验证码而不是网页扫码?因为扫码要配微信开放平台(付费、要审核),公众号验证码是公众号自带的能力(免费、轻量)。对独立开发者来说,"够用且成本最低"又一次赢了。

微信登录的 Redis 键设计也值得一提:wx:login:code:wx:login:openid:wx:login:req:code:wx:login:req:scene:——前缀隔离了不同用途的 key,每个都有明确的过期时间。验证码的存储用的是"code → openId"的映射,配合 5 分钟过期,让"验证码换身份"这件事有了时间窗口的安全边界。临时凭证的设计,核心就是"短命 + 一次有效 + 有明确映射"——验证码是临时的身份兑换券,不是长期的身份标识。

还有 sceneId 的概念——公众号消息里带一个场景值,用来区分"这是登录请求"还是"其他操作"。微信的消息是异步回调的,服务端拿到消息要先判断"用户想干嘛",sceneId 就是那个"意图标签"。协议对接里,判断意图的字段和承载数据的字段要分开,这才能支撑一个公众号同时服务多个功能。

公众号消息的收发还有个现实约束:微信服务器要求 5 秒内回复,超时就算失败、会重发。所以 WxController 的处理逻辑都是"能秒回的先回、耗时的异步做"——验证码生成是秒回,但真正触发登录是在用户网页端输入验证码之后,不在公众号消息的回调里。对接外部系统,要清楚对方的超时边界,把"快速确认"和"慢速业务"拆开,否则一个慢接口就把整条回调链路拖死。

注册和登录的限流

登录相关的接口都挂着限流。看注册的:

@PostMapping("/register")
@BucketRateLimit(key = "user_register", dimension = RateLimitDimension.IP,
        permitsPerMinute = 0, permitsPerHour = 3, message = "注册过于频繁,请稍后再试")
public BaseResponse<Long> userRegister(...)

每 IP 每小时只允许注册 3 次。permitsPerMinute = 0 有意思——分钟级直接归零,意思是"别想在一分钟里连发多次",只给小时级额度。为什么这么狠?因为注册接口是垃圾账号的入口——脚本批量注册、刷验证码、养号,全靠这个接口。按 IP 限得死一点,正常用户(一小时注册一次)完全无感,脚本的批量操作直接卡死。

登录接口同样限流,但策略不同(每用户维度,失败重试也不至于把自己锁死)。限流的紧松,要看这个接口被滥用的后果:注册滥用的后果是满站垃圾号,所以限得最狠;登录滥用的后果是暴力破解,所以限流要配合"失败记录 + 风险检测"一起用,而不是一刀切。

这里要展开说一句:限流是"刹车",但刹车不能替代"看路"。注册限流把批量注册挡在外面,但真正的安全还要靠前面那些——失败记录(看谁在试)、风险检测(判断新设备新 IP)、分级通知(让用户知道)。限流是"让攻击者慢下来",审计是"让攻击者无处遁形",两者配合才是完整的安全策略。只上刹车不看路,攻击者换个 IP 就绕过去了;只看路不刹车,服务先被刷垮了。

还有个细节:登录接口的限流维度是"每用户"——这是为了不误伤共享 IP 的用户。但"每用户"对未登录的暴力破解(不知道用户名、只试密码)是无效的,因为攻击者压根没有"用户"维度。所以这类接口往往还要叠加 IP 维度的补充限流。限流维度的选择,要贴着"攻击者是什么维度"来选——攻击者按 IP 来,你就按 IP 限;攻击者按用户来,你就按用户限。

前端:登录记录页和请求拦截器

前端有两块和登录直接相关:request.ts 的请求拦截器和 LoginRecordManagePage.vue 登录记录页。

request.ts 的关键是 withCredentials: true——Sa-Token 的 token 在 cookie 里,不加这个,跨域请求不会带上 cookie,登录状态就丢了。它还做了全局错误翻译:后端硬编码的中文错误("未登录""无权限")在响应拦截器里统一转成前端多语言文案。这解决了一个很实际的问题——后端返回的中文错误信息,在英文版站点上会显示中文,全局翻译后就不再串味。

登录记录页展示每次登录的完整信息:

// 设备图标(按设备类型)
const deviceIcon = getDeviceIcon(record.deviceType)
// 状态点(按登录状态 + 风险等级配色)
const statusClass = getStatusClass(record.loginStatus, record.riskLevel)
// 地点、IP、风险原因
record.loginLocation || $t('...unknownLocation')
record.loginIp
record.riskReason // 只有 riskLevel > 0 时显示

页面上一眼能看到:这次登录用的什么设备、什么时间、从哪个城市、哪个 IP、有没有风险提示。还专门统计了"风险记录数"——页面上标记出那些高危/可疑的登录,让用户不用逐条翻。登录记录页的价值,是把后端存的审计数据变成用户能读懂的"安全感"——你看到自己所有登录记录都正常,放心;看到一条不认识的"新设备登录",警惕。

前端还有一层全局的"登录态"管理:用户登录后,登录信息(用户 ID、角色等)存在 Pinia store 里,路由守卫在进入需要登录的页面时检查 store——没登录就跳去登录页。这层和 request.ts 的 401 处理是"双保险":路由守卫管"前端知道不该进",响应拦截器管"后端发现没登录就引导"。前端的登录态管理,是"路由守卫 + 请求拦截器"两道闸,少了任何一道,用户都会在"半登录"的状态里迷路。

登录记录页的"风险记录数"统计也很贴心——records.filter(r => r.riskLevel > 0).length,把高危/可疑的记录单独标出来。这其实是把后端的安全判断翻译成前端的一个"警示数字":用户看到"你有 3 条风险登录记录",比逐条翻看更直观。把审计数据变成用户能一眼看懂的安全信号,是登录记录页真正的价值

登录记录页还有个常见的交互:按时间倒序展示,最新一次登录在最上面。这个排序不是随便定的——用户最关心的是"最近有没有我没见过的登录",最新的在最上面,一眼扫到。往下的历史记录折叠或者分页,避免页面被几千条记录拖垮。列表页的排序和分页,是体验的一半,尤其是登录记录这种"最近最重要"的数据。

踩坑记录

这条登录链路的坑,我按"身份、凭证、审计"三块归了类——分组看,每块的坑往往是同一个根源。

身份这块的坑。 最初直接用 request.getRemoteAddr() 拿 IP,结果全站登录记录都显示"服务器机房所在城市"——所有请求都过 Nginx,getRemoteAddr 拿到的是 Nginx 的 IP。改成 ServletUtils.getClientIP 从代理头链取真实 IP 才准。凡是拿客户端 IP,一定要考虑代理链,这是 IP 相关功能的第一课。还有个"记了等于白记"的坑:登录记录里的 sessionId,最初以为是 Servlet 的 session,但项目没启用 spring-session,这个 session 每次请求新建,记了没意义——后来改记 Sa-Token 的 token。审计字段要确保它真的承载信息,记一个没意义的字段比不记更误导。

凭证这块的坑。 微信登录的验证码最初取一次不删,同一个码能重复登录——改成"取即删"(get 到就删 Redis key)才好,一次性凭证的"一次性"靠取完就删。登录接口被脚本爆破也在这组:按用户限流,脚本随机试密码根本不登录成功,用户维度形同虚设——叠加 IP 维度限流 + 失败记录按 IP 聚合告警才压住。安全措施要针对攻击者的实际行为设计——攻击者按 IP 打,你的防线就得有 IP 维度。

审计这块的坑。 暴力破解脚本每小时发几千次失败请求,每次记一条,MySQL 表蹭蹭涨——加上失败记录去重和阈值(同 IP 短时间只记一条聚合的)才控制住,审计日志也要限流,记录本身不能成为攻击入口。多设备踢下线最初把管理员也踢了——加了角色判断,管理员不受多设备限制,安全逻辑要分角色,一刀切最误伤。异地登录误报被当成 bug:出差用户第一次在新城市登录被标高危,收到"异常登录提醒"一脸懵——后来文案加"如果是本人可忽略",让用户能一键确认刷新基线,安全提示要自带解除误报的出口。还有 User-Agent 太长存库报错——加长度截断,客户端输入要落库的,长度校验是底线

收尾:登录的功夫,在密码之外

把这条登录链路收起来,你会发现:"验密码"只是登录的最表层。真正的功夫在验证完之后——会话怎么管、登录怎么记、风险怎么判、异常怎么通知、多设备怎么控。这五件事凑在一起,才把"登录"从"一个入口"变成"一整套安全体系"。

三句话总结这篇:

会话要能管、能踢。 Sa-Token + Redis 让会话跨实例共享,logoutByLoginId 让管理员能远程踢人——会话管理不只是登录/登出,还有"可回收"。

登录要记全、要分级。 IP、设备、城市、风险等级、失败原因,一条记录记全;风险按"常用 IP + 常用设备"的行为基线判定,新 IP 新设备就标高危。

异常要主动通知。 登录记录从"被动审计"到"主动防御"的转折,是让"该知道的人"在第一时间知道——新设备登录提醒,比事后追查有效一百倍。

如果你也要做登录,不妨照着这条链检查:你的会话能跨实例、能远程踢吗?你的登录记录能告诉用户"谁在哪用什么登的"吗?你的异常登录,用户会第一时间知道吗?最后说句掏心窝的:这篇的每一处,单独看都不是什么惊艳的技术——Sa-Token 是流行框架,ip2region 是现成库,风险检测是几个 if 判断。但把它们串成一个"登录即审计、异常即通知"的闭环,让一次登录从"验密码"变成"记全案 + 判风险 + 推提醒",这才是这个系统区别于"能用就行"的地方。安全不是一个功能,是一套贯穿登录前后的习惯。希望这篇能把这套习惯传给你。

登录做到这里,"能用"和"可信"之间的差距也就三个问题:会话能跨实例、能远程踢吗?登录记录能说清"谁在哪用什么登的"吗?异常登录,该知道的人会第一时间知道吗?三个都答对,登录才真正可信。